bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
/* agents.css — the "automated programs on this machine" view (uisanction.go).
ADDITIONS ONLY, the same rule given.css:1-10 states: app.css carries the page
and every colour here is one of its variables rather than a literal, so the
three pages cannot drift into three different looks.
No inline style= anywhere. The page's own CSP is `default-src 'self'`
(uisanction.go renderAgents), which covers style-src, so an inline style is
silently dropped and the policy wins against the markup. app.css:169-173
records the one time that happened. */
a { color: var(--ink); }
a:hover { color: var(--warn); }
.backlink { max-width: 46rem; margin: 0 auto 1.25rem; font-size: 0.85rem; }
/* ── one observation ───────────────────────────────────────────────────────
The left edge carries the state so a column of twenty can be read down
without reading a word: amber is something nobody has vouched for, green is
something that has been, red is a verdict this machine could not reach.
Deliberately NOT red for an unvouched-for program. Most of what lands there
on a working machine is the user's own editor before they have got round to
marking it, and a page that cries alarm at ordinary state is a page that gets
ignored the one time it matters — the same reasoning given.css:20-25 gives
for a granted capability. */
.obs {
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 6px;
background: var(--panel);
padding: 0.85rem 1.1rem;
margin-bottom: 0.6rem;
}
.obs.bare { border-left-color: var(--warn); }
.obs.vouched { border-left-color: var(--ok); }
.obs.unknown { border-left-color: var(--bad); }
.obs.dormant { border-left-color: var(--line); }
/* An EXPIRED mark (a v1 record — sanction.go) takes the warn edge, not the
dormant grey: a dormant mark still vouches and simply has nothing running
behind it, while this one has stopped vouching and needs the user to act. */
.obs.expired { border-left-color: var(--warn); }
.obs-head { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
.obs-head .obs-name { font-weight: 600; word-break: break-all; }
.obs-head .tag { margin-left: 0; }
/* The kind chip: a classification, not a verdict, so it takes app.css's neutral
line colour rather than any of the three states. An `agent` chip in red would
put most of a working machine's list in alarm colours, which is the failure
given.css:20-25 already names for a granted capability. */
.tag.kind { background: var(--line); color: var(--dim); }
.obs-why { margin: 0.45rem 0 0; font-size: 0.85rem; }
.obs-why.dim { color: var(--dim); }
.obs-foot { margin: 0; color: var(--dim); font-size: 0.8rem; }
.obs-acts { display: flex; gap: 0.6rem; margin-top: 0.8rem; flex-wrap: wrap; }
.obs-acts form { margin: 0; }
/* ── the evidence ──────────────────────────────────────────────────────────
One signal per line. The plane reports a LIST of typed reasons rather than a
verdict (origin.go:51-57), and running them together into a sentence is
exactly the compression that turns evidence back into a verdict. */
.evidence {
margin: 0.6rem 0 0;
padding: 0;
list-style: none;
font-size: 0.78rem;
color: var(--dim);
}
.evidence li { margin-top: 0.15rem; word-break: break-word; }
.tier {
display: inline-block;
min-width: 5.2rem;
padding: 0.02rem 0.4rem;
margin-right: 0.4rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
text-align: center;
}
.tier.verified { background: rgba(78,169,107,0.16); color: var(--ok); }
.tier.known { background: rgba(215,154,60,0.16); color: var(--warn); }
.tier.ambiguous { background: rgba(154,161,173,0.16); color: var(--dim); }
+244
View File
@@ -0,0 +1,244 @@
{{define "agents-page"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — automated programs on this machine</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/agents.css">
<!-- htmx's own indicator stylesheet is injected with insertAdjacentHTML and is
blocked by this page's CSP (default-src 'self', no style-src escape, no
nonce). Turn the injection off rather than widen the policy — see
page.html for the full reasoning. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>Automated programs on this machine</h1>
<p class="sub">Everything this machine can see that runs on its own or talks to a
model, and which of them you have said are yours.</p>
</header>
<p class="backlink"><a href="/">&larr; This machine</a></p>
<!-- Ten seconds, not the status page's three. Producing this list walks every
process and reads every established connection (agents_linux.go), which
measured 24ms here — nothing, but it is not a liveness probe and nothing on
this page changes at three-second speed. -->
<main id="agents"
hx-get="/fragments/agents"
hx-trigger="every 10s"
hx-swap="innerHTML">
{{template "agents" .}}
</main>
<footer>
privatae-bastion {{.Version}} &middot; <a href="/">this machine</a> &middot; served
from this machine only and not reachable from anywhere else.
</footer>
</body>
</html>
{{end}}
{{define "agents"}}
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{/* A build with no enumerator has to say so. "Nothing is running here" and
"nothing looked" read identically otherwise, and the second is the machine
you should worry about (agents_other.go, the README, "Observe"). */}}
{{if .Blind}}
<div class="state bad">
<h2>This machine cannot watch itself.</h2>
<p>This build of the bastion has no way to list processes or connections on this
platform, so the list below is empty because nothing looked — not because
nothing is running. Do not read it as a clean machine.</p>
</div>
{{end}}
{{/* A read that FAILED is not a machine with nothing on it. Every verdict below
is unknown while this is true, and the page must not draw a tick. */}}
{{/* The walk failed for THIS render: Blind for one pass, not for the platform. */}}
{{if .WalkError}}
<div class="state bad">
<h2>This machine could not read its processes just now.</h2>
<p>{{.WalkError}}</p>
<p>The list below is empty because nothing looked — not because nothing is
running. Reload to try again.</p>
</div>
{{end}}
{{if .ReadError}}
<div class="state bad">
<h2>This machine cannot read its own record of what you marked as yours.</h2>
<p>{{.ReadError}}</p>
<p>Until that is fixed nothing below can be called vouched for or not vouched
for, and nothing here should be treated as unwanted.</p>
</div>
{{end}}
{{/* ...and not under the walk banner either: "this machine looked" is the one
sentence a failed walk must never draw. */}}
{{if not .Blind}}{{if not .ReadError}}{{if not .WalkError}}
<div class="state {{if .Unsanctioned}}warn{{else}}ok{{end}}">
{{if .Unsanctioned}}
<h2>{{.Unsanctioned}} of {{.Observed}} are not marked as yours.</h2>
<p>Marking something as yours is you vouching for it. What is left over is the
set worth looking at.</p>
{{else if .Observed}}
<h2>All {{.Observed}} are accounted for.</h2>
<p>Everything this machine can see has either been launched by your host or
marked as yours here.</p>
{{else}}
<h2>Nothing automated is running on this machine.</h2>
<p>No agent, automation or process talking to a model was found. This machine
looked; it did not find anything.</p>
{{end}}
</div>
{{end}}{{end}}{{end}}
<div class="note">
<strong>This page does not stop anything.</strong> There is no button here that
closes a program. Stopping something running on this machine is directed by your
host, and saying that a program is yours is a different act from taking it away.
</div>
{{range .Rows}}
<div class="obs {{if .Unknown}}unknown{{else if .Sanctioned}}vouched{{else}}bare{{end}}">
<div class="obs-head">
<span class="obs-name">{{if .Signature}}{{.Signature}}{{else}}(unnamed){{end}}</span>
{{/* The kind is a CLASSIFICATION and takes no colour. Being an agent is not
a fault — most of what is on this list on a working machine is the
user's own editor — and the chip beside it already carries the only
verdict this page makes. */}}
<span class="tag kind">{{.Kind}}</span>
{{if .Unknown}}<span class="tag bad">unknown</span>
{{else if .Sanctioned}}<span class="tag ok">yours</span>
{{else}}<span class="tag warn">not marked as yours</span>{{end}}
</div>
<p class="obs-why">{{.Why}}</p>
{{if .App}}
<p class="obs-why dim">This is software you are running rather than an agent, and
this machine refuses to close it however your host asks. It is listed and
counted because it talks to a model, not because it is suspected of anything.</p>
{{end}}
<dl class="host-facts">
<dt>Process</dt><dd>{{.PID}}{{if .User}}, running as {{.User}}{{end}}</dd>
{{if .Origin}}<dt>Started</dt><dd>{{.Origin}}</dd>{{end}}
{{/* The FILE and the COMMAND are two different facts and the page shows
both. A mark is recorded against the file — the kernel's answer, which
the program cannot choose — while the command line is the name it gave
itself. Showing only the second is what made a mark forgeable. */}}
{{if .Exe}}<dt>File</dt><dd><code>{{.Exe}}</code></dd>{{end}}
{{if .Cmdline}}<dt>Command</dt><dd><code>{{.Cmdline}}</code></dd>{{end}}
{{if .Endpoints}}
<dt>Talking to</dt>
<dd>{{range $i, $e := .Endpoints}}{{if $i}}, {{end}}<code>{{$e}}</code>{{end}}</dd>
{{end}}
</dl>
{{/* The evidence list, not a verdict — the same thing the host is shown
(origin.go:53). A person deciding whether to vouch for something needs to
see what made this machine notice it. */}}
{{if .Evidence}}
<ul class="evidence">
{{range .Evidence}}
<li><span class="tier {{.Tier}}">{{.Tier}}</span> {{.Signal}}{{if .Value}} &middot; {{.Value}}{{end}}</li>
{{end}}
</ul>
{{end}}
{{if not .Unknown}}
<div class="obs-acts">
{{if .Marked}}
<form hx-post="/agents/unmark" hx-target="#agents" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
<button class="no" type="submit">Unmark</button>
</form>
{{else if .Sanctioned}}
<p class="obs-foot">Your host vouched for this one. There is nothing to unmark
here — the tag it carries was issued by your host and is withdrawn there.</p>
{{else if .ID}}
<form hx-post="/agents/mark" hx-target="#agents" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
<button class="go" type="submit">This one is mine</button>
</form>
{{else}}
{{/* No kernel path, so there is no identity to mark and no button that
could do anything but fail. The reason above already says why; a
disabled control here would only imply the machine is withholding
something it could do. */}}
<p class="obs-foot">Nothing here can be marked as yours until this machine can
read which file it is running.</p>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .Dormant}}
<h3>Marked as yours, but not running right now</h3>
<p class="sub">These stay on this machine until you remove them, which is the only
way a mark ever goes away. They are listed so a mark you no longer want cannot
sit here unseen.</p>
{{range .Dormant}}
<div class="obs dormant">
<div class="obs-head">
<span class="obs-name">{{.What}}</span>
<span class="tag ok">yours</span>
</div>
<dl class="host-facts">
<dt>Marked</dt>
<dd>{{.At}}{{if .By}}, from {{.By}}{{end}}{{if .ByUser}}, by {{.ByUser}}{{end}}</dd>
{{if .Cmdline}}<dt>Looked like</dt><dd><code>{{.Cmdline}}</code></dd>{{end}}
</dl>
<div class="obs-acts">
<form hx-post="/agents/unmark" hx-target="#agents" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
<button class="no" type="submit">Unmark</button>
</form>
</div>
</div>
{{end}}
{{end}}
{{if .Expired}}
<h3>Marks that no longer count</h3>
<p class="sub">These were recorded against the name a program gave for itself, which
anything on this machine can copy by starting a process with that name. A mark is
now recorded against the FILE this machine's kernel says a process is running,
which a program cannot choose for itself. The old marks below vouch for nothing.
They are still shown because they are your record of a decision you made, and
this program does not throw those away quietly. To mark the same program again,
find it in the list above while it is running and press &ldquo;This one is
mine&rdquo;.</p>
{{range .Expired}}
<div class="obs expired">
<div class="obs-head">
<span class="obs-name">{{.What}}</span>
<span class="tag warn">no longer counts</span>
</div>
<dl class="host-facts">
<dt>Marked</dt>
<dd>{{.At}}{{if .By}}, from {{.By}}{{end}}{{if .ByUser}}, by {{.ByUser}}{{end}}</dd>
{{if .Cmdline}}<dt>Looked like</dt><dd><code>{{.Cmdline}}</code></dd>{{end}}
</dl>
<div class="obs-acts">
<form hx-post="/agents/unmark" hx-target="#agents" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
<button class="no" type="submit">Remove</button>
</form>
</div>
</div>
{{end}}
{{end}}
<p class="sub checked-at">Checked {{.CheckedAt}}. What this machine can see here is
deliberately soft: a program that does not want to be noticed can rename itself,
and a mark is a convenience, not a lock.</p>
{{end}}
+188
View File
@@ -0,0 +1,188 @@
/* The local UI's whole stylesheet. Plain, literal, no framework — the same
voice as the product's copy. Served from the binary, never from a CDN. */
:root {
--bg: #16181d;
--panel: #1e2128;
--line: #2c313b;
--ink: #e6e8ec;
--dim: #9aa1ad;
--ok: #4ea96b;
--warn: #d79a3c;
--bad: #d1584f;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem 1.5rem 4rem;
background: var(--bg);
color: var(--ink);
font: 15px/1.55 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
main, header, footer { max-width: 46rem; margin: 0 auto; }
header { margin-bottom: 1.5rem; }
h1 { margin: 0; font-size: 1.35rem; font-weight: 600; letter-spacing: -0.01em; }
.sub { margin: 0.15rem 0 0; color: var(--dim); font-size: 0.85rem; }
.state {
padding: 1rem 1.1rem;
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 6px;
background: var(--panel);
margin-bottom: 1.25rem;
}
.state.ok { border-left-color: var(--ok); }
.state.warn { border-left-color: var(--warn); }
.state.bad { border-left-color: var(--bad); }
.state h2 { margin: 0; font-size: 1rem; font-weight: 600; }
.state p { margin: 0.45rem 0 0; color: var(--dim); font-size: 0.87rem; }
/* ── waiting for you ───────────────────────────────────────────────────────
The only thing on this page that is time-critical, so it is the only thing
allowed to shout. */
h3.urgent { color: var(--warn); font-size: 0.85rem; margin-top: 0; }
.flash {
padding: 0.7rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.88rem;
border: 1px solid var(--line);
}
.flash.ok { border-color: var(--ok); color: var(--ok); }
.flash.warn { border-color: var(--warn); color: var(--warn); }
.flash.bad { border-color: var(--bad); color: var(--bad); }
.approval {
border: 1px solid var(--warn);
border-radius: 6px;
background: var(--panel);
padding: 1rem 1.1rem;
margin-bottom: 0.8rem;
}
.approval-head { font-weight: 600; }
.approval-head .id {
color: var(--dim); font-weight: 400;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
}
.approval-line { margin: 0.45rem 0 0; font-size: 0.88rem; white-space: pre-wrap; }
.approval.unreadable { border-color: var(--bad); }
.approval.unreadable .approval-line strong { color: var(--bad); }
.approval-acts { display: flex; gap: 0.6rem; margin-top: 0.9rem; flex-wrap: wrap; }
/* Deny is written first in the markup so it leads for keyboard and screen
readers; on a describable request the visual order goes back to Approve
first, where it belongs. */
.approval-acts:not(.reversed) { flex-direction: row-reverse; justify-content: flex-end; }
.approval-acts form { display: flex; gap: 0.4rem; margin: 0; }
.approval-foot { margin: 0.7rem 0 0; color: var(--dim); font-size: 0.78rem; }
input.code {
width: 9rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--bg);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
letter-spacing: 0.08em;
}
input.code:focus { outline: 2px solid var(--warn); outline-offset: 1px; }
button {
padding: 0.45rem 1rem;
border-radius: 5px;
border: 1px solid var(--line);
background: var(--line);
color: var(--ink);
font: inherit;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
button.go { background: var(--ok); border-color: var(--ok); color: #0d1410; }
button.no { background: transparent; color: var(--dim); }
button:hover { filter: brightness(1.12); }
/* ── what has happened here ────────────────────────────────────────────────
The same card as .host, deliberately NOT the .approval treatment: an approval
is a question the page is holding open, and these are answers to questions
nobody asked. A recent one — or a live refusal — takes the bad border,
because the host killing your programs is the one thing on this page you did
not do and were never told about. The border is the whole difference; an
event is never hidden once it stops being recent, it only stops shouting. */
.event {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
padding: 0.85rem 1.1rem;
margin-bottom: 0.6rem;
}
.event.recent, .event.refused { border-color: var(--bad); }
.event-when { color: var(--dim); font-size: 0.78rem; }
.event-line { margin: 0.35rem 0 0; font-size: 0.88rem; }
.event-foot { margin: 0.65rem 0 0; color: var(--dim); font-size: 0.78rem; }
.host {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
padding: 0.85rem 1.1rem;
margin-bottom: 0.6rem;
}
.host-name { font-weight: 600; word-break: break-all; }
.host-facts {
margin: 0.5rem 0 0;
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: 0.2rem 0.75rem;
font-size: 0.85rem;
}
.host-facts dt { color: var(--dim); }
.host-facts dd { margin: 0; word-break: break-all; }
.tag {
display: inline-block;
margin-left: 0.5rem;
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
vertical-align: 0.08em;
}
.tag.ok { background: rgba(78,169,107,0.16); color: var(--ok); }
.tag.warn { background: rgba(215,154,60,0.16); color: var(--warn); }
.tag.bad { background: rgba(209,88,79,0.16); color: var(--bad); }
h3 { margin: 1.6rem 0 0.6rem; font-size: 0.8rem; font-weight: 600;
color: var(--dim); text-transform: none; letter-spacing: 0; }
/* Was an inline style="margin-top:1rem" until review pointed out the page's own
CSP (`default-src 'self'`, ui.go) covers style-src and therefore blocks it —
the policy was silently winning against the markup. A class costs nothing and
keeps the CSP with no 'unsafe-inline' escape hatch in it. */
.checked-at { margin-top: 1rem; }
footer { margin-top: 2.5rem; color: var(--dim); font-size: 0.8rem; }
footer a { color: var(--dim); }
.note {
margin-top: 0.6rem;
padding: 0.7rem 0.9rem;
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--dim);
font-size: 0.84rem;
}
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em; color: var(--ink); }
+110
View File
@@ -0,0 +1,110 @@
{{define "approvals.html"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae — approvals</title>
<link rel="stylesheet" href="/assets/app.css">
<!-- Same CSP reasoning as page.html: htmx must not inject its indicator styles. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>Approvals</h1>
<p class="sub">What your hosts are waiting on you for.{{if not .TrayView}} <a href="/">This machine</a>{{end}}</p>
</header>
<!-- NOT polled, unlike the front page. The list is redrawn only when the person
acts; a timer that redrew it would move cards under the cursor, wipe a
half-typed code, and put a just-arrived ask into "approve all" before
anyone read it. The news line inside the fragment is what polls. -->
<main id="approvals">
{{template "approvals" .}}
</main>
</body>
</html>
{{end}}
{{define "approvals"}}
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{if .TrayError}}<div class="flash bad">{{.TrayError}}</div>{{end}}
{{/* The ids this list was drawn with. The news poll sends them and is told
only whether the set has changed since; the list itself stays still. */}}
<form id="approvals-shown" hidden>
{{range .DeviceApprovals}}<input type="hidden" name="shown" value="{{.ID}}">{{end}}
{{range .Approvals}}<input type="hidden" name="shown" value="{{.ID}}">{{end}}
</form>
<div id="approvals-news"
hx-get="/fragments/approvals-news"
hx-include="#approvals-shown"
hx-trigger="every 3s"
hx-swap="innerHTML"></div>
{{if .DeviceApprovals}}
<h3 class="urgent">Waiting for your approval</h3>
{{range .DeviceApprovals}}
<div class="approval">
<div class="approval-head">For {{.Server}} <span class="id">{{.Short}}</span></div>
{{range .Lines}}<p class="approval-line">{{.}}</p>{{end}}
<div class="approval-acts">
<form hx-post="/device/deny" hx-target="#approvals" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
<button class="no" type="submit">Deny</button>
</form>
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
{{if .NeedsCode}}<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="your code" required>{{end}}
<button class="go" type="submit">Approve</button>
</form>
</div>
{{if .Plan}}
<p class="approval-foot">Asked {{.At}}. Approving signs this ship plan with this device's key:
each of {{.Server}} runs only its own steps of it, once, within its time.
{{if .NeedsCode}}It runs root work, so it needs your code.{{end}} Your host cannot sign it.</p>
{{else}}
<p class="approval-foot">Asked {{.At}}. Approving signs it with this device's key: it
may run once, on {{.Server}} only, within the hour. Your host cannot sign it.</p>
{{end}}
</div>
{{end}}
{{if gt .NoCodeApprovals 1}}
{{/* "All" means all SHOWN: the form carries the ids drawn above and nothing
else, so an ask that lands after this was drawn is left waiting. A plan
that runs root work is never in it: it needs the code, one at a time. */}}
<form class="approve-all" hx-post="/device/approve-shown" hx-target="#approvals" hx-swap="innerHTML">
{{range .DeviceApprovals}}{{if not .NeedsCode}}<input type="hidden" name="id" value="{{.ID}}">{{end}}{{end}}
<button class="go" type="submit">Approve all {{.NoCodeApprovals}} shown</button>
</form>
{{end}}
{{end}}
{{if .Approvals}}
<h3 class="urgent">Waiting for your code on this machine</h3>
{{template "stepup-approvals" .}}
{{end}}
{{if and (not .DeviceApprovals) (not .Approvals)}}
<p class="approval-line">Nothing is waiting for your approval.</p>
{{if .TrayView}}
<p class="approval-foot">When your Host asks for something on this machine, it shows here and
this window asks for a code from the authenticator app enrolled when this machine's bastion was set up.</p>
{{else if not .IsDevice}}
<p class="approval-foot">This machine is not your approval device, so asks from your
hosts are not answered here. Run <code>privatae-bastion device-init</code> on the machine
you want to approve from.</p>
{{end}}
{{end}}
<p class="sub checked-at">Checked {{.CheckedAt}}.</p>
{{end}}
{{define "approvals-news"}}
{{if or .Arrived .Left}}
<div class="flash warn">
{{if .Arrived}}{{.Arrived}} new request{{if gt .Arrived 1}}s{{end}} arrived{{end}}{{if and .Arrived .Left}}, and {{end}}{{if .Left}}{{.Left}} {{if gt .Left 1}}were{{else}}was{{end}} answered elsewhere or lapsed{{end}}.
<button type="button" hx-get="/fragments/approvals" hx-target="#approvals" hx-swap="innerHTML">Show them</button>
</div>
{{end}}
{{end}}
+62
View File
@@ -0,0 +1,62 @@
/* context.css — the user-run context surface.
Layered on diskmap.css, which the page also loads: the treemap, the crumbs
and the accounting blocks are the same thing on both surfaces and are styled
once. Only what is NEW here is below. */
.context-page header h1 { margin-bottom: .15rem; }
/* The ask. Deliberately quiet rather than a call to action: the page should
read as somewhere you look at your files, with handing one over as an
available act, not the purpose. */
.context-ask {
margin: 1.4rem 0;
padding: 1rem 1.1rem;
border: 1px solid var(--line, #2a3038);
border-radius: 6px;
background: var(--panel, #151a20);
}
.context-ask h3 { margin-top: 0; }
.context-ask p { margin: .45rem 0; }
.context-ask form {
margin-top: .85rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .6rem;
}
.context-ask label {
display: flex;
align-items: center;
gap: .4rem;
}
.context-ask input[type=text] {
padding: .35rem .5rem;
border: 1px solid var(--line, #2a3038);
border-radius: 4px;
background: var(--bg, #0f1317);
color: inherit;
min-width: 14rem;
}
/* What has been asked for. The unsent ones are not styled as an error the
person caused — they are a statement about the machine, so they are marked
but not alarming. */
.context-asks { list-style: none; padding-left: 0; }
.context-asks li {
padding: .4rem .6rem;
margin: .3rem 0;
border-left: 3px solid var(--line, #2a3038);
}
.context-asks li.ok { border-left-color: var(--ok, #4f9d69); }
.context-asks li.bad { border-left-color: var(--warn, #b9862f); }
/* What is already context. Above the map on purpose: the page is named for
this, and a file browser is what you use to ADD to it. */
.ctx-held { width: 100%; border-collapse: collapse; margin: .5rem 0 1.2rem; }
.ctx-held th, .ctx-held td {
text-align: left;
padding: .35rem .6rem;
border-bottom: 1px solid var(--line, #2a3038);
vertical-align: top;
}
.ctx-held th { font-weight: 600; opacity: .8; }
+203
View File
@@ -0,0 +1,203 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Your context</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/diskmap.css">
<link rel="stylesheet" href="/assets/context.css">
<!-- Same CSP note as every other page here: htmx injects its indicator styles
with insertAdjacentHTML, which `default-src 'self'` blocks. Turn the
injection off rather than widen the policy. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body class="dm-page context-page">
<header>
<h1>Your context</h1>
<p class="sub">{{.Home}}</p>
<p class="sub">Your files are not your context. This is where you choose which
of them <strong>become</strong> context on your network — everything else stays
exactly where it is and is never read.</p>
<p class="sub">This is running <strong>as you</strong>, so what it can show is
exactly what you can already open. It reads <strong>names, sizes and the date
each thing last changed</strong> — it never opens a file and never reads what
is inside one.</p>
</header>
<!-- NO POLL ON THIS <main>, for the reason diskmap.html gives: re-running a
walk off a timer is the one thing this feature must never do. The fragment
below carries its own trigger, and only while a walk is running. -->
<main>
{{template "context" .}}
</main>
<footer>
privatae-bastion {{.Version}} &middot; your context, from your files &middot; served from this
machine to you, and not reachable from anywhere else.
</footer>
</body>
</html>
{{define "context"}}
<div id="context-live"
{{if .Map.Scanning}}hx-get="/fragments/context{{.Map.Query}}" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{/* WHAT IS ALREADY CONTEXT, FIRST. A page called "Your context" that opens
with a file browser answers every question except the one in its title. */}}
<h3>What is context now</h3>
{{if .Held}}
<table class="ctx-held">
<thead><tr><th scope="col">Folder</th><th scope="col">Host</th><th scope="col">It may</th><th scope="col">Re-reads</th><th scope="col">Until</th></tr></thead>
<tbody>
{{range .Held}}
<tr>
<td>{{if .Readable}}{{.Root}}{{else}}(store {{.StoreID}} — this machine cannot read its record){{end}}</td>
<td>{{.Host}}</td>
<td>{{range .What}}{{.}} {{end}}</td>
<td>{{.Sweep}}</td>
<td>{{.Expires}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .HeldNote}}<p class="sub">{{.HeldNote}}</p>{{end}}
{{if .Map.Scanning}}
<div class="state warn">
<h2>COUNTING UP YOUR FILES</h2>
<p>{{.Map.Entries}} things counted, {{.Map.SeenSize}} so far &middot; running for {{.Map.Running}}.</p>
{{if .Map.SeenAt}}<p class="dm-at">{{.Map.SeenAt}}</p>{{end}}
<p>It keeps going if you close this page. Come back and it will be here.</p>
<div class="dm-acts">
<form hx-post="/context/stop" hx-target="#context-live" hx-swap="outerHTML">
<button class="no" type="submit">Stop</button>
</form>
</div>
</div>
{{end}}
{{if not .Map.HaveMap}}
{{if not .Map.Scanning}}
<div class="state">
<h2>NOTHING COUNTED YET</h2>
<p>Counting walks your folders and adds up what is in them. On a big home
directory that takes a while, and it runs in the background.</p>
<div class="dm-acts">
<form hx-post="/context/scan" hx-target="#context-live" hx-swap="outerHTML">
<button class="go" type="submit">Count up my files</button>
</form>
</div>
{{range .Map.RootNotes}}<p class="sub">{{.}}</p>{{end}}
</div>
{{end}}
{{else}}
{{if .Map.NotFound}}
<div class="flash warn">That folder is not in what was counted. It may be new —
count again to pick it up.</div>
{{end}}
{{/* THE ACCOUNTING FIRST, above the picture. Everything here is a way the map
is INCOMPLETE, and a caveat under a pretty picture is a caveat nobody
reads. Denied matters more on this surface than on the admin one: a folder
you cannot read is the normal case for a person, not an anomaly. */}}
{{if .Map.Capped}}
<div class="note">This stopped after {{.Map.CappedAt}} — there was more than it
counts in one pass, so what is below that point was never looked at.</div>
{{end}}
{{if .Map.Cancelled}}
<div class="note">You stopped this one, so it is partial. What it had already
counted is kept.</div>
{{end}}
{{if .Map.Denied}}
<div class="note">
{{.Map.Denied}} things could not be read by you and are not counted here.
That is the operating system saying no, and it is normal — some of your own
folders are managed by applications.
{{if .Map.DeniedPaths}}<ul class="dm-paths">{{range .Map.DeniedPaths}}<li>{{.}}</li>{{end}}</ul>{{end}}
</div>
{{end}}
<nav class="dm-crumbs">
{{range .Map.Crumbs}}<a href="/?p={{.Path}}">{{.Name}}</a> / {{end}}
</nav>
<div class="dm-total">{{.Map.Total}} &middot; {{.Map.Items}} things directly in here</div>
<div class="dm-map">{{.Map.SVG}}</div>
{{if .Map.Path}}
{{/* THE ASK. One folder, named, with what happens next said before the button
rather than after it. "Share" would be a lie: nothing is shared by pressing
this — a person on the host has to approve it with a code first. */}}
<div class="context-ask">
<h3>Make this folder part of your context</h3>
<p>A bastion agent reads one folder <strong>on this machine</strong>, indexes it
here, and answers questions about it with personal details removed. Your
files do not move and nothing is copied off this computer.</p>
<p class="sub">Pressing this does not share anything. It sends a request. Somebody
has to approve it on your host, with a code, before a single file is read.</p>
<form hx-post="/context/ask" hx-target="#context-live" hx-swap="outerHTML">
<input type="hidden" name="path" value="{{.Map.Path}}">
<input type="hidden" name="p" value="{{.Map.Path}}">
<label>Call it
<input type="text" name="name" value="{{.Map.Leaf}}" maxlength="60">
</label>
<button class="go" type="submit">Ask to add {{.Map.Leaf}} to your context</button>
</form>
</div>
{{end}}
{{if .Map.Biggest}}
<h3>The biggest things here</h3>
<table class="dm-rows">
<thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Share</th><th scope="col">Kind</th><th scope="col">Changed</th></tr></thead>
<tbody>
{{range .Map.Biggest}}
<tr>
<td class="dm-name">
<svg class="dm-swatch" viewBox="0 0 10 10" aria-hidden="true"><rect width="10" height="10" fill="{{.Fill}}"/></svg>
{{if .Dir}}<a href="/?p={{.Path}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}
</td>
<td class="dm-size">{{.Size}}</td>
<td class="dm-share">{{.Share}}%</td>
<td class="dm-kind">{{.Kind}}</td>
<td class="dm-when">{{.Modified}}</td>
</tr>
{{end}}
</tbody>
</table>
{{if .Map.MoreRow}}<p class="sub">and {{.Map.MoreRow}} more, all smaller than these.</p>{{end}}
{{end}}
<div class="dm-acts">
<form hx-post="/context/scan" hx-target="#context-live" hx-swap="outerHTML">
<input type="hidden" name="p" value="{{.Map.Path}}">
<button class="go" type="submit">Count my files again</button>
</form>
</div>
{{end}}
{{if .Asks}}
<h3>What you have asked to add</h3>
<p class="sub">These are requests you made. They are kept here so closing this window does not lose them; whether each was approved is the host's answer, not this machine's.</p>
<ul class="context-asks">
{{range .Asks}}
<li class="{{if .Sent}}ok{{else}}bad{{end}}">
<strong>{{.Path}}</strong>
{{if .Sent}}— asked {{.Host}} at {{.At}}. Waiting for somebody there to approve it.
{{else}}— not sent at {{.At}}. {{.Note}}{{end}}
</li>
{{end}}
</ul>
{{end}}
<p class="sub checked-at">Checked {{.Map.CheckedAt}}.</p>
</div>
{{end}}
+126
View File
@@ -0,0 +1,126 @@
/* The disk map's own stylesheet, loaded beside app.css.
NOTHING IN HERE KNOWS A FILE-TYPE COLOUR. The map's colours live in one
place — dmKinds in uidiskmap.go — and reach the page as SVG fill attributes,
including the little squares in the legend. A .dm-kind-video rule here would
be a second copy of that table which nothing forces to agree with the first,
and the day they disagreed the legend would be confidently mislabelling the
picture beside it. */
/* app.css caps main/header/footer at 46rem, which is right for a page of
sentences and wrong for a picture: at 46rem a 1000-unit treemap scales to
about 736px and the smallest blocks stop being clickable. Widened only on
this page, by a class on <body>, so nothing else moves. */
.dm-page main,
.dm-page header,
.dm-page footer,
.dm-page .backlink { max-width: 74rem; margin-left: auto; margin-right: auto; }
.backlink { margin: 0 auto 1.25rem; font-size: 0.85rem; }
.backlink a { color: var(--dim); }
/* ── the map ─────────────────────────────────────────────────────────────── */
/* The viewBox is 1000x620; this is what turns it into whatever the page is
wide. height:auto keeps the aspect, which the layout depends on — a squashed
viewBox would make every aspect-ratio decision dmSquarify made a lie. */
.dm-svg {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
}
/* A folder block is a link, so it gets the pointer the rest of the map does
not. This is the only hint on the page that some blocks go somewhere. */
.dm-svg a { cursor: pointer; }
.dm-svg a:focus rect { outline: 2px solid var(--ink); }
.dm-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0 0 0.6rem;
}
.dm-crumbs { font-size: 0.9rem; word-break: break-all; }
.dm-crumbs a { color: var(--dim); }
.dm-crumbs .here { font-weight: 600; }
.dm-crumbs .sep { color: var(--line); }
.dm-total { color: var(--dim); font-size: 0.85rem; white-space: nowrap; }
.dm-drawn { margin-top: 0.5rem; }
.dm-at { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.78rem; word-break: break-all; }
.dm-acts { display: flex; gap: 0.6rem; margin-top: 0.9rem; flex-wrap: wrap; }
.dm-acts form { margin: 0; }
/* ── the legend ──────────────────────────────────────────────────────────── */
.dm-swatch {
width: 0.7rem;
height: 0.7rem;
border-radius: 2px;
vertical-align: -0.05rem;
margin-right: 0.45rem;
flex: none;
}
.dm-legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.25rem 1.2rem;
font-size: 0.85rem;
}
.dm-legend li { display: flex; align-items: center; gap: 0.5rem; }
.dm-legend-label { flex: 1; }
.dm-legend-size { font-weight: 600; }
.dm-legend-items { color: var(--dim); font-size: 0.78rem; white-space: nowrap; }
/* ── the biggest things ──────────────────────────────────────────────────── */
.dm-rows {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.dm-rows th {
text-align: left;
font-weight: 600;
color: var(--dim);
font-size: 0.78rem;
padding: 0.3rem 0.6rem 0.3rem 0;
border-bottom: 1px solid var(--line);
}
.dm-rows td {
padding: 0.35rem 0.6rem 0.35rem 0;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.dm-rows .dm-name { word-break: break-all; }
.dm-rows .dm-name a { color: var(--ink); }
.dm-rows .dm-size, .dm-rows .dm-share { white-space: nowrap; font-variant-numeric: tabular-nums; }
.dm-rows .dm-kind, .dm-rows .dm-when { color: var(--dim); white-space: nowrap; }
/* ── about ───────────────────────────────────────────────────────────────── */
.dm-about { margin-top: 0.4rem; }
/* The roots are links. Without this they take the browser's default blue,
which on this palette is the one bright thing on a dark page and reads as an
error rather than as navigation. */
.dm-about a { color: var(--ink); }
.dm-about .host-facts dd { word-break: break-all; }
.dm-paths {
margin: 0.5rem 0 0;
padding-left: 1.1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.78rem;
word-break: break-all;
}
+257
View File
@@ -0,0 +1,257 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — {{if .Leaf}}{{.Leaf}}{{else}}this machine's disk{{end}}</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/diskmap.css">
<!-- htmx's own indicator stylesheet is injected with insertAdjacentHTML and is
blocked by this page's CSP (default-src 'self', no style-src escape, no
nonce). Turn the injection off rather than widen the policy — see
page.html for the full reasoning. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body class="dm-page">
<header>
<h1>This machine's disk</h1>
<p class="sub">Every rectangle is a file or a folder, drawn at the size of the
space it takes up. Hover one to see what it is. Click a folder to go in.</p>
<p class="sub">This reads <strong>names, sizes and the date each thing last
changed</strong>. It never opens a file and never reads what is inside one.</p>
</header>
<p class="backlink"><a href="/">&larr; This machine</a></p>
<!-- NO POLL ON THIS <main>. The status page re-fetches itself every three
seconds (page.html) and the given page every ten, and this one must not:
re-running a disk walk off a timer is the single thing this feature is
forbidden to do. The fragment below carries its own hx-trigger and ONLY
while a scan is running, so an idle map makes no requests at all. -->
<main>
{{template "diskmap" .}}
</main>
<footer>
privatae-bastion {{.Version}} &middot; <a href="/">this machine</a> &middot;
<a href="/given">what this machine has given away</a> &middot; served from this
machine only and not reachable from anywhere else.
</footer>
</body>
</html>
{{define "diskmap"}}
{{/* hx-swap="outerHTML" on this element and the poll attributes only while a
scan runs: when the scan finishes, the fragment that replaces this one
carries no hx-get, so the polling stops by itself rather than being
switched off by script. */}}
<div id="dm-live"
{{if .Scanning}}hx-get="/fragments/diskmap{{.Query}}" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{if .Scanning}}
<div class="state warn">
<h2>MAPPING {{if eq .Scope "machine"}}THIS WHOLE MACHINE{{else}}YOUR FILES{{end}}</h2>
<p>{{.Entries}} things counted, {{.SeenSize}} so far &middot; running for {{.Running}}.</p>
{{if .SeenAt}}<p class="dm-at">{{.SeenAt}}</p>{{end}}
<p>It keeps going if you close this page. Come back and it will be here.</p>
<div class="dm-acts">
<form hx-post="/diskmap/stop" hx-target="#dm-live" hx-swap="outerHTML">
<button class="no" type="submit">Stop</button>
</form>
</div>
</div>
{{end}}
{{if not .HaveMap}}
{{if not .Scanning}}
<div class="state">
<h2>NO MAP YET</h2>
<p>Nothing has been counted yet. Mapping walks the folders it can reach and
adds up what is in them — on a large disk that takes a while, and it runs
in the background.</p>
<div class="dm-acts">
<form hx-post="/diskmap/scan" hx-target="#dm-live" hx-swap="outerHTML">
<input type="hidden" name="scope" value="home">
<button class="go" type="submit">Map my files</button>
</form>
</div>
{{range .RootNotes}}<p class="sub">{{.}}</p>{{end}}
{{/* THE WHOLE MACHINE IS A SEPARATE REQUEST: a full drive map is something
a person asks for. It reads into every account on the
box, so it is asked for on its own, below the default, and it NAMES
what it will walk before it is pressed. */}}
<div class="note">
<p>Mapping the whole machine is a bigger thing to ask for: it walks every
drive, including folders that belong to other people who use this
computer. It covers:</p>
<ul class="dm-paths">{{range .MachineRoots}}<li>{{.}}</li>{{end}}</ul>
<form hx-post="/diskmap/scan" hx-target="#dm-live" hx-swap="outerHTML">
<input type="hidden" name="scope" value="machine">
<button class="no" type="submit">Map this whole machine</button>
</form>
</div>
</div>
{{end}}
{{else}}
{{if .NotFound}}
<div class="flash warn">That folder is not in this map. It may have been made
after the map was taken — map the machine again to pick it up.</div>
{{end}}
{{/* THE ACCOUNTING BLOCK, above the picture and not below it. Everything here
is a way the map is INCOMPLETE, and a caveat under a pretty picture is a
caveat nobody reads. */}}
{{if .Capped}}
<div class="flash bad">This map stopped after counting as much as it is willing
to hold in memory, at <code>{{.CappedAt}}</code>. Everything drawn is real and
everything under that point is missing.</div>
{{end}}
{{if .Cancelled}}
<div class="flash warn">This map was stopped before it finished, so it is a
partial count. What is drawn is real; there is more on the disk.</div>
{{end}}
{{if .Stale}}
<div class="flash warn">This map was taken {{.TakenAgo}} and things will have
changed since. Map again for a current picture.</div>
{{end}}
<div class="dm-head">
<nav class="dm-crumbs" aria-label="Where you are">
{{range .Crumbs}}{{if .Here}}<span class="here">{{.Name}}</span>{{else}}<a
href="/diskmap?p={{.Path}}">{{.Name}}</a> <span class="sep">/</span> {{end}}{{end}}
</nav>
<div class="dm-total">{{.Total}} &middot; {{.Items}} things directly in here</div>
</div>
{{.SVG}}
<p class="sub dm-drawn">
{{.Cells}} blocks drawn.
{{if .Tiny}}{{.Tiny}} more are too small to draw at this size ({{.TinySize}} in
total) — they are still counted in the block they sit inside. Click a folder
to see them.{{end}}
{{if .Overflow}}The picture stopped at its own limit of blocks; go into a
folder to see the rest of it.{{end}}
</p>
{{if .Legend}}
<h3>What is in here</h3>
<ul class="dm-legend">
{{range .Legend}}
<li>
{{/* The swatch is a one-rect SVG rather than a CSS class per type, so the
colours have ONE definition (dmKinds, in Go) instead of a copy in a
stylesheet that would quietly stop matching the map. An SVG fill is a
presentation attribute, so it needs no style-src hole either. */}}
<svg class="dm-swatch" viewBox="0 0 10 10" aria-hidden="true"><rect width="10" height="10" fill="{{.Fill}}"/></svg>
<span class="dm-legend-label">{{.Label}}</span>
<span class="dm-legend-size">{{.Size}}</span>
<span class="dm-legend-items">{{.Items}} files</span>
</li>
{{end}}
</ul>
{{end}}
{{if .Biggest}}
<h3>The biggest things here</h3>
<table class="dm-rows">
<thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Share</th><th scope="col">Kind</th><th scope="col">Changed</th></tr></thead>
<tbody>
{{range .Biggest}}
<tr>
<td class="dm-name">
<svg class="dm-swatch" viewBox="0 0 10 10" aria-hidden="true"><rect width="10" height="10" fill="{{.Fill}}"/></svg>
{{if .Dir}}<a href="/diskmap?p={{.Path}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}
</td>
<td class="dm-size">{{.Size}}</td>
<td class="dm-share">{{.Share}}%</td>
<td class="dm-kind">{{.Kind}}</td>
<td class="dm-when">{{.Modified}}</td>
</tr>
{{end}}
</tbody>
</table>
{{if .MoreRow}}<p class="sub">and {{.MoreRow}} more, all smaller than these.</p>{{end}}
{{end}}
<h3>About this map</h3>
<div class="dm-about">
<dl class="host-facts">
<dt>This map is</dt>
<dd>{{if eq .Scope "machine"}}the whole machine — every drive, including
folders belonging to other people who use this computer{{else}}your files
only{{end}}</dd>
<dt>Taken</dt><dd>{{.TakenAgo}}, and it took {{.TookFor}}</dd>
<dt>Covers</dt>
<dd>{{range .Roots}}<a href="/diskmap?p={{.Path}}">{{.Path}}</a> — {{.Size}}<br>{{end}}</dd>
</dl>
{{range .RootNotes}}<p class="sub">{{.}}</p>{{end}}
{{/* WHAT IS NOT IN THE MAP. This is the honest half of the page and it is
written out rather than summarised: a user comparing this total against
what their operating system says the disk holds is entitled to every
reason the two differ. */}}
{{if .Denied}}
<div class="note">
<strong>{{.Denied}} places could not be read</strong>, so whatever is inside
them is not counted anywhere in this map. That is the operating system
refusing this program, which is how it should be.
{{if .DeniedPaths}}
<ul class="dm-paths">{{range .DeniedPaths}}<li>{{.}}</li>{{end}}</ul>
{{end}}
</div>
{{end}}
{{if .Crossings}}
<div class="note">
<strong>{{len .Crossings}} other disks or mounts start inside these
folders</strong> and were not entered — they are separate storage and are
not part of this map.
<ul class="dm-paths">{{range .Crossings}}<li>{{.}}</li>{{end}}</ul>
</div>
{{end}}
{{if .Skipped}}
<div class="note">
These are not real storage — they are the operating system pretending to be
folders, and one file in them claims to be larger than every disk you own:
<ul class="dm-paths">{{range .Skipped}}<li>{{.}}</li>{{end}}</ul>
</div>
{{end}}
<div class="dm-acts">
<form hx-post="/diskmap/scan" hx-target="#dm-live" hx-swap="outerHTML">
{{/* Where the user is standing, so a re-scan does not silently drop them
at the root of the disk. If that folder is gone by the time the new
map lands, the page says so — it never redirects quietly. */}}
<input type="hidden" name="p" value="{{.Path}}">
{{/* And WHAT they were looking at. A re-scan repeats the same scope:
pressing "again" must never quietly widen what gets read. */}}
<input type="hidden" name="scope" value="{{.Scope}}">
<button class="go" type="submit">Map {{.ScopeWords}} again</button>
</form>
</div>
{{if ne .Scope "machine"}}
<div class="note">
<p>This map is your files only. Mapping the whole machine walks every drive,
including folders that belong to other people who use this computer, and
it covers:</p>
<ul class="dm-paths">{{range .MachineRoots}}<li>{{.}}</li>{{end}}</ul>
<form hx-post="/diskmap/scan" hx-target="#dm-live" hx-swap="outerHTML">
<input type="hidden" name="scope" value="machine">
<button class="no" type="submit">Map this whole machine</button>
</form>
</div>
{{end}}
</div>
{{end}}
<p class="sub checked-at">Checked {{.CheckedAt}}.</p>
</div>
{{end}}
+59
View File
@@ -0,0 +1,59 @@
/* given.css — the "what this machine has given away" view (uigiven.go).
ADDITIONS ONLY. app.css still carries the page, and every colour here is one
of its variables rather than a literal, so the two files cannot drift into
two different looks — this page and the status page have to read as the same
program or a user will not believe either of them.
No inline style= anywhere, for the reason app.css already records: the page's
own CSP is `default-src 'self'` (ui.go), which covers style-src, so an inline
style is silently dropped and the policy wins against the markup. */
/* app.css styles `footer a` only, because nothing outside the footer linked
anywhere until this page. An unstyled link on this background renders in the
browser's default blue, which on #16181d is close to unreadable. */
a { color: var(--ink); }
a:hover { color: var(--warn); }
.backlink { max-width: 46rem; margin: 0 auto 1.25rem; font-size: 0.85rem; }
/* ── one capability ────────────────────────────────────────────────────────
The left edge carries the verdict so a column of seven can be read down
without reading a word: amber is something this machine gives away, green is
something it refuses. Deliberately NOT red for a granted capability — a
grant the user made is not a fault, and a page that cries error at normal
state is a page that gets ignored the one time it matters. */
.cap {
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 6px;
background: var(--panel);
padding: 0.8rem 1.1rem;
margin-bottom: 0.5rem;
}
.cap.held { border-left-color: var(--warn); }
.cap.refused { border-left-color: var(--ok); }
.cap-head { display: flex; gap: 0.6rem; align-items: baseline; flex-wrap: wrap; }
.cap-head .cap-line { font-weight: 600; }
.cap-head .tag { margin-left: 0; }
.cap p { margin: 0.4rem 0 0; font-size: 0.85rem; }
.cap .cap-second { color: var(--dim); }
.cap .cap-ops { color: var(--dim); font-size: 0.78rem; margin-top: 0.55rem; }
.legend { margin-bottom: 0.6rem; }
.legend .tag { margin-left: 0; margin-right: 0.35rem; }
/* One granted operation per line: a mask is a list of separate permissions and
running them together reads as one sentence the user then has to unpick. */
.mask { display: block; }
.host.unreadable { border-color: var(--bad); }
/* The local switch. Quiet by default — this page is still mostly something you
read — and the "off" state is the one that gets the emphasis, because a
capability you have cut is a fact about the machine worth seeing at a glance. */
.cap-switch { display: inline; margin-left: .5rem; }
.cap-switch button { font-size: .85em; padding: .15rem .5rem; }
+193
View File
@@ -0,0 +1,193 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — what this machine has given away</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/given.css">
<!-- htmx's own indicator stylesheet is injected with insertAdjacentHTML and is
blocked by this page's CSP (default-src 'self', no style-src escape, no
nonce). Turn the injection off rather than widen the policy — see
page.html for the full reasoning. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>What this machine has given away</h1>
<p class="sub">Every host paired here, what it may do to this machine, and which
of your folders it can reach.</p>
</header>
<p class="backlink"><a href="/">&larr; This machine</a></p>
<!-- Ten seconds, not three. The status page polls at 3s because liveness
changes at that speed; nothing on this page moves unless a host syncs a
grant or the anchor signs one, and every poll re-verifies an ed25519
signature per pairing and per folder. -->
<main id="given"
hx-get="/fragments/given"
hx-trigger="every 10s"
hx-swap="innerHTML">
{{template "given" .}}
</main>
<footer>
privatae-bastion {{.Version}} &middot; <a href="/">this machine</a> &middot; served
from this machine only and not reachable from anywhere else.
</footer>
</body>
</html>
{{define "given"}}
{{/* THE OUTCOME OF THE LAST SWITCH. It was composed in givenSwitch and never
rendered — the tag flipped because it draws from state, so nothing looked
broken and the sentence that says what the machine will now DO never
reached the person. */}}
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{/* AND WHEN THE SWITCHES ARE NOT IN EFFECT. Without this the page renders
every capability as granted while a corrupt list quietly blocks nothing —
showing the person the opposite of the truth. */}}
{{if .DenyNote}}<div class="flash bad">{{.DenyNote}}</div>{{end}}
<div class="state">
<h2>This is your machine's own control.</h2>
<p>Every capability below can be <strong>switched off here</strong>, whatever your
Host was granted. Switching off never changes the grant — this machine simply
refuses, and tells the host why. Turning one back on clears that refusal and
nothing more, so nothing on this page can give a host reach it was not already
granted.</p>
<p>A folder stops being shared when you remove it on your Host, which drops the
grant here. A host stops being able to do anything at all when you run
<code>privatae-bastion unpair &lt;host&gt;</code> on this machine.</p>
</div>
{{/* A read that FAILED is not a machine with nothing on it. Rendering the
reassuring sentence for an unreadable pairing store is the misleading
direction, and this page exists to stop exactly that. */}}
{{if .ReadError}}
<div class="host unreadable">
<div class="host-name">This machine's pairing records cannot be read.</div>
<p class="sub">{{.ReadError}}</p>
<p class="sub">Until that is fixed, this page cannot tell you what has been given
away — and you should not read it as "nothing".</p>
</div>
{{end}}
{{if .NoPairings}}
<div class="host">
<div class="host-name">Nothing. No host is paired with this machine.</div>
<p class="sub">Nothing can read this screen, read your files or open a shell
here until you pair one.</p>
</div>
{{end}}
{{range $h := .Hosts}}
<div class="host">
<div class="host-name">
{{.Host}}
{{if .IsAnchor}}<span class="tag warn">anchor</span>{{end}}
{{if not .Served}}<span class="tag bad">{{.State}}</span>{{end}}
</div>
{{if not .Served}}
<p class="sub">This pairing is {{.State}}, so nothing is being served to it right
now — no tunnel, no lanes, no directories. What follows is what it would hold
the moment it became active again.</p>
{{end}}
<div class="note">{{.Basis}}</div>
</div>
<h3>What {{.Host}} may do to this machine</h3>
<p class="sub legend">
<span class="tag warn">granted</span>is something this machine gives away.
<span class="tag ok">refused here</span>is something it will not do.
{{if .HeldCount}}{{.HeldCount}} of {{len .Families}} are granted.{{else}}None of them are granted.{{end}}
</p>
{{range .Families}}
<div class="cap {{if .Held}}held{{else}}refused{{end}}">
<div class="cap-head">
<span class="cap-line">{{.Line}}</span>
{{if .SwitchedOff}}<span class="tag ok">switched off by you</span>
{{else if .Held}}<span class="tag warn">granted</span>
{{else}}<span class="tag ok">refused here</span>{{end}}
{{/* THE SWITCH. Only ever removes reach: "off" writes a local denial and
"on" clears it, leaving the host's signed grant exactly as it was. */}}
<form class="cap-switch" hx-post="/given/switch" hx-target="#given" hx-swap="innerHTML">
<input type="hidden" name="host" value="{{$h.Host}}">
<input type="hidden" name="family" value="{{.Name}}">
{{if .SwitchedOff}}
<input type="hidden" name="off" value="0">
<button class="go" type="submit">Turn back on</button>
{{else}}
<input type="hidden" name="off" value="1">
<button class="no" type="submit">Switch off here</button>
{{end}}
</form>
</div>
{{if .SwitchedOff}}<p class="cap-detail"><strong>You switched this off on this
machine.</strong> {{$h.Host}} still holds the grant; this machine refuses and
tells it why.</p>{{end}}
<p class="cap-detail">{{.Detail}}</p>
{{if .Second}}<p class="cap-second"><strong>Beyond this grant:</strong> {{.Second}}</p>{{end}}
<p class="cap-ops">{{.Name}}
{{range .Ops}} &middot; <code>{{.}}</code>{{end}}</p>
</div>
{{end}}
<h3>Folders of yours that {{.Host}} can reach from here</h3>
{{if .FolderNote}}<div class="note">{{.FolderNote}}</div>{{end}}
{{range .Folders}}
<div class="host">
<div class="host-name">
{{if .Readable}}{{.Root}}{{else}}store {{.StoreID}}{{end}}
{{if .InForce}}<span class="tag warn">shared</span>
{{else}}<span class="tag ok">not in force</span>{{end}}
</div>
{{if not .InForce}}
<p class="sub">This machine will not serve this folder: {{.Why}}. The record is
still on disk, which is why it is listed rather than hidden.</p>
{{end}}
{{if not .Readable}}
<p class="sub">The signed part of this record cannot be read on this machine, so
there is nothing honest to say about what it covers. It grants nothing.</p>
{{end}}
{{if .Readable}}
<dl class="host-facts">
<dt>Store</dt><dd>{{.StoreID}}</dd>
<dt>Store kind</dt><dd>{{.Class}}</dd>
<dt>Allowed</dt>
<dd>{{if .MaskWords}}
{{range .MaskWords}}<span class="mask">{{.}}</span>{{end}}
{{else}}nothing — the grant names no operation, so every read of this folder
is refused{{end}}</dd>
<dt>Excluded</dt>
<dd>{{if .Excludes}}the grant excludes
{{range $i, $e := .Excludes}}{{if $i}}, {{end}}<code>{{$e}}</code>{{end}}
{{else}}the grant names no exclusions at all{{end}}</dd>
<dt>Expires</dt>
<dd>{{if .Expires}}{{.Expires}}
{{else}}the grant carries no expiry, so it stands until it is removed on your
Host{{end}}</dd>
{{if .Sweep}}<dt>Swept</dt><dd>{{.Sweep}}</dd>{{end}}
{{if .TTLDays}}<dt>Shown to you as</dt><dd>{{.TTLDays}} days</dd>{{end}}
</dl>
{{if .Sensitive}}
<div class="note">The grant marks this folder <strong>sensitive</strong>.</div>
{{end}}
{{if .Sealed}}
<div class="note">The catalogue of this folder is sealed to your registered backup
key. That key travels outside the signed part of the record, so it is not one of
the things you approved when you approved the folder.</div>
{{end}}
{{end}}
</div>
{{end}}
{{end}}
<p class="sub checked-at">Checked {{.CheckedAt}}. This machine is in
<strong>{{.Mode}}</strong> mode{{if .Anchored}} and is anchored{{else}} and is not
anchored{{end}}.</p>
{{end}}
+13
View File
@@ -0,0 +1,13 @@
Zero-Clause BSD
=============
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — this machine</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/given.css">
<!-- htmx injects its own indicator stylesheet with insertAdjacentHTML (it does
`<style>` + `.htmx-indicator{opacity:0...}` when includeIndicatorStyles is
true, which is its default). This page's CSP is `default-src 'self'` with no
style-src escape and no nonce, so the browser BLOCKS that injection and logs
a violation on every load. Nothing here uses hx-indicator, so the effect is
nil and the console noise is the whole cost — but a CSP that is violated on
every page teaches whoever reads the console to ignore it, which is the one
thing a CSP cannot afford.
Turn the injection OFF rather than widen the policy. Found by the disk-map
unit while building against this CSP; it fires on the pages shipped in
0.25.0 too, so it predates that work. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>This machine</h1>
<p class="sub">The bastion running here, and what it is doing.</p>
<!-- The links off this page, all in the header rather than the footer on
purpose. None of these is a footnote: which host may read this screen,
read these files or open a root shell here; the way in for someone who
has not set the machine up yet; what is running here that nobody has
vouched for; and what a rule would stop if it were allowed to. They sit
outside <main>, so the 3s htmx swap never touches them.
Five units each added one link without knowing about the others, and
each merge duplicated the block. Resolved by hand
at integration; the order below is deliberate — set it up, then what you
gave away, then what is on the disk, then what is running, then what a
rule would do about it. -->
<p class="sub">
<a href="/approvals">Approvals</a> &middot;
<a href="/setup">Set this machine up</a> &middot;
<a href="/given">What this machine has given away</a> &middot;
<a href="/diskmap">This machine's disk</a> &middot;
<a href="/agents">Automated programs running here</a> &middot;
<a href="/policy">What would be stopped</a>
</p>
</header>
<!-- The ONE piece of htmx in the page: re-fetch the same fragment the server
rendered below, every few seconds, and swap it in. No client-side state,
no JSON, no duplicate rendering path — the fragment is the truth. -->
<main id="status"
hx-get="/fragments/status"
hx-trigger="every 3s"
hx-swap="innerHTML">
{{template "status" .}}
</main>
<footer>
privatae-bastion {{.Version}} · this page is served from this machine only and
is not reachable from anywhere else.
</footer>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
/* policy.css — the "what would be stopped" view (uipolicy.go).
ADDITIONS ONLY, the rule given.css:1-10 states and agents.css:3-5 repeats:
app.css carries the page, and every colour here is one of its variables rather
than a literal, so the pages cannot drift into different looks.
It repeats the observation-card rules that agents.css also has rather than
linking that file. Each page in this UI stands on app.css plus its own sheet
(given.css, setup.css, agents.css), and borrowing another unit's sheet would
mean this page changes shape whenever that unit restyles theirs. Forty lines
of duplication against a page that silently reflows: the duplication is the
cheaper of the two.
No inline style= anywhere. This page's CSP is `default-src 'self'`
(uipolicy.go renderPolicy), which covers style-src, so an inline style is
dropped and the policy wins against the markup. app.css:169-173 records the
one time that happened. */
a { color: var(--ink); }
a:hover { color: var(--warn); }
.backlink { max-width: 46rem; margin: 0 auto 1.25rem; font-size: 0.85rem; }
h3 { max-width: 46rem; margin: 1.6rem auto 0.5rem; font-size: 0.95rem; }
h3 + .sub { margin-bottom: 0.8rem; }
/* ── the rule itself ───────────────────────────────────────────────────────
Shown on the page because a person asked to review false positives cannot do
it against a rule they have to read the source to find. */
.rule {
max-width: 46rem;
margin: 0 auto 1.25rem;
padding: 0.85rem 1.1rem 0.85rem 2.2rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
font-size: 0.87rem;
}
.rule li { margin-top: 0.3rem; }
.rule li:first-child { margin-top: 0; }
.term {
font-weight: 600;
color: var(--ink);
margin-right: 0.3rem;
}
/* ── one observation ───────────────────────────────────────────────────────
The left edge carries the verdict so a column of twenty reads down without
reading a word: amber is something this rule would stop, green is something
it leaves alone, red is a verdict it could not reach.
Amber and not red for a would-stop, deliberately. Nothing has been stopped —
the page is a rehearsal — and a rehearsal that renders in alarm colours is a
page people stop opening, which is the one failure this measurement cannot
survive (agents.css:17-26 sets the same order for the same reason). Red is
kept for the state that genuinely needs a person: a verdict this machine
could not reach. */
.obs {
max-width: 46rem;
margin: 0 auto 0.6rem;
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 6px;
background: var(--panel);
padding: 0.85rem 1.1rem;
}
.obs.bare { border-left-color: var(--warn); }
.obs.vouched { border-left-color: var(--ok); }
.obs.unknown { border-left-color: var(--bad); }
.obs.dormant { border-left-color: var(--line); }
.obs.past { border-style: dashed; border-left-style: solid; }
.obs-head { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
.obs-head .obs-name { font-weight: 600; word-break: break-all; }
.obs-why { margin: 0.45rem 0 0; font-size: 0.85rem; color: var(--dim); }
/* The kind chip is a CLASSIFICATION and takes no colour: being an agent is not
a fault, and most of what is on this list on a working machine is the user's
own editor. The chip beside it carries the only verdict this page makes. */
.tag.kind { background: var(--line); color: var(--dim); }
/* ── the terms behind one verdict ──────────────────────────────────────────
Every term, not only the one that decided it, one per line. The plane this
reads from reports a LIST of typed reasons rather than a verdict
(origin.go:51-57); running them together into a sentence is exactly the
compression that turns evidence back into a verdict. */
.terms {
margin: 0.6rem 0 0;
padding: 0;
list-style: none;
font-size: 0.78rem;
color: var(--dim);
}
.terms li {
margin-top: 0.25rem;
padding-left: 0.6rem;
border-left: 2px solid var(--line);
word-break: break-word;
}
.terms li.points-stop { border-left-color: var(--warn); }
.terms li.points-leave { border-left-color: var(--ok); }
.terms li.points-unknown { border-left-color: var(--bad); }
.tier {
display: inline-block;
min-width: 5.2rem;
padding: 0.02rem 0.4rem;
margin-right: 0.4rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
text-align: center;
}
.tier.verified { background: rgba(78,169,107,0.16); color: var(--ok); }
.tier.known { background: rgba(215,154,60,0.16); color: var(--warn); }
.tier.ambiguous { background: rgba(154,161,173,0.16); color: var(--dim); }
+269
View File
@@ -0,0 +1,269 @@
{{define "policy-page"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — what would be stopped</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/policy.css">
<!-- htmx's own indicator stylesheet is injected with insertAdjacentHTML and is
blocked by this page's CSP (default-src 'self', no style-src escape, no
nonce). Turn the injection off rather than widen the policy — see
page.html for the full reasoning. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>What would be stopped</h1>
<p class="sub">A rule this machine runs against everything automated it can see,
and a record of what it would have stopped while nobody was watching. It is a
rehearsal: nothing here has any power to close a program.</p>
</header>
<p class="backlink"><a href="/">&larr; This machine</a> &middot;
<a href="/agents">Automated programs running here</a></p>
<!-- Ten seconds, like the agents page and for the same reason: producing this
list walks every process and every established connection (24ms measured),
and nothing on this page changes at the status page's three-second speed. -->
<main id="policy"
hx-get="/fragments/policy"
hx-trigger="every 10s"
hx-swap="innerHTML">
{{template "policy" .}}
</main>
<footer>
privatae-bastion {{.Version}} &middot; <a href="/">this machine</a> &middot; served
from this machine only and not reachable from anywhere else.
</footer>
</body>
</html>
{{end}}
{{define "policy"}}
{{/* A build with no enumerator has to say so. "Nothing would be stopped" and
"nothing looked" read identically otherwise, and the second is the machine
you should worry about (agents_other.go, the README, "Observe"). */}}
{{if .Blind}}
<div class="state bad">
<h2>This machine cannot watch itself.</h2>
<p>This build of the bastion has no way to list processes or connections on this
platform. The rule below has nothing to run against, so read the empty list as
"nothing looked", never as "nothing is running".</p>
</div>
{{end}}
{{/* A read that FAILED is not a machine with nothing on it. Every verdict below
is unreachable while this is true, and no count is worth printing. */}}
{{if .RegistryError}}
<div class="state bad">
<h2>This machine cannot read its own record of what you marked as yours.</h2>
<p>{{.RegistryError}}</p>
<p>Every judgement below needs that record, so this rule cannot judge anything
until it is fixed. It is not reporting that there is nothing to judge.</p>
</div>
{{end}}
{{/* A walk that FAILED for this render is Blind for one pass: the rows below are
empty because nothing looked. Said before the rows, for the same reason. */}}
{{if .WalkError}}
<div class="state bad">
<h2>This machine could not read its processes just now.</h2>
<p>{{.WalkError}}</p>
<p>The rule had nothing to run against on this render, so read the empty list
as "nothing looked", never as "nothing would have been stopped". Reload to
try again.</p>
</div>
{{end}}
{{if .CensusError}}
<div class="state bad">
<h2>The record of what this rule would have stopped is not being written.</h2>
<p>{{.CensusError}}{{if .CensusErrorAt}} &middot; {{.CensusErrorAt}}{{end}}</p>
<p>The list further down is whatever was written before that started. Do not
read it as up to date.</p>
</div>
{{end}}
{{/* ...and not under the walk banner: a failed walk must never draw the tick. */}}
{{if not .Blind}}{{if not .RegistryError}}{{if not .WalkError}}
<div class="state {{if .WouldStop}}warn{{else}}ok{{end}}">
{{if .WouldStop}}
<h2>{{.WouldStop}} of {{.Observed}} would be stopped by this rule.</h2>
<p>Nothing has been stopped. This is what the rule would do if it were ever
allowed to act, listed so you can decide whether it would have been right.</p>
{{else if .Observed}}
<h2>Nothing running here would be stopped by this rule.</h2>
<p>{{.Observed}} automated programs were looked at. Every one of them is either
something a person is running, or something that has been vouched for.</p>
{{else}}
<h2>Nothing automated is running on this machine.</h2>
<p>This machine looked; it did not find anything for the rule to run against.</p>
{{end}}
{{if .CannotJudge}}
<p><strong>{{.CannotJudge}} of {{.Observed}} cannot be judged at all.</strong>
A term this rule needs could not be read for them. That is a blind spot, not a
clean bill of health.</p>
{{end}}
</div>
{{end}}{{end}}{{end}}
<div class="note">
<strong>This page cannot stop anything.</strong> There is no button here, and no
route behind this page that closes a program. Stopping something on this machine
is directed by your host. This rule is measured first so that whether it should
ever act is a decision somebody makes with evidence.
</div>
{{/* The rule, in the same words for everyone who reads this page. It lives
beside the code that implements it (policy.go policyRuleLines) so that
changing a term and forgetting the sentence is one file's mistake. */}}
<h3>The rule</h3>
<p class="sub">A program is proposed for stopping only when all four are true of it
at once.</p>
<ul class="rule">
{{range .Rule}}
<li><span class="term">{{.Term}}</span> {{.Detail}}</li>
{{end}}
</ul>
{{if .Rows}}
<h3>Right now</h3>
{{end}}
{{range .Rows}}
<div class="obs {{if eq .Decision "would-stop"}}bare{{else if eq .Decision "cannot-judge"}}unknown{{else}}vouched{{end}}">
<div class="obs-head">
<span class="obs-name">{{if .Signature}}{{.Signature}}{{else}}(unnamed){{end}}</span>
<span class="tag kind">{{.Kind}}</span>
<span class="tag {{.Class}}">{{.Word}}</span>
</div>
<dl class="host-facts">
<dt>Process</dt><dd>{{.PID}}{{if .User}}, running as {{.User}}{{end}}</dd>
{{if .Origin}}<dt>Started</dt><dd>{{.Origin}}</dd>{{end}}
{{if .Cmdline}}<dt>Command</dt><dd><code>{{.Cmdline}}</code></dd>{{end}}
{{if .Endpoints}}
<dt>Talking to</dt>
<dd>{{range $i, $e := .Endpoints}}{{if $i}}, {{end}}<code>{{$e}}</code>{{end}}</dd>
{{end}}
</dl>
{{/* Every term, not only the one that decided it. A verdict nobody can audit
is a verdict nobody should trust, and the plane it reads from emits typed
evidence rather than a bare answer for the same reason (agents.go:183-188). */}}
<ul class="terms">
{{range .Reasons}}
<li class="points-{{.Points}}">
<span class="tier {{.Tier}}">{{.Tier}}</span>
<span class="term">{{.Term}}</span> {{.Detail}}
</li>
{{end}}
</ul>
</div>
{{end}}
{{/* ── the census ────────────────────────────────────────────────────────────
The question step 4 is waiting on is not "what would be stopped now" but
"what would this have killed over a week, and would that have been wrong".
A page that only rendered the current scan would answer neither. */}}
<h3>What this would have stopped</h3>
{{/* A read that FAILED is not a machine that never measured anything. The two
were one branch until a review of this file caught it: an unparseable record
would have rendered the most reassuring sentence on the page. */}}
{{if .CensusUnreadable}}
<p class="sub">This machine could not read that record, so nothing is listed here
and the space below it is not an absence. The banner at the top of the page says
why. Until that is fixed the honest answer is "not known".</p>
{{else if not .CensusRan}}
<div class="state bad">
<h2>This machine has not taken this measurement yet.</h2>
<p>The record is written by the running bastion, once a minute. If the bastion is
not running here, or has only just started, there is nothing to show — which is
not the same as nothing having happened.</p>
</div>
{{else}}
<p class="sub">Measured since {{.CensusSince}} — {{.CensusWindow}}. Last looked
{{.CensusLast}}. A program that appears and exits between two scans is not seen
at all, and the times below are kept to about five minutes so that watching this
does not mean writing to your disk every minute.</p>
{{if .CensusStale}}
<div class="state bad">
<h2>This measurement has stopped.</h2>
<p>The last scan was {{.CensusLast}}, which is longer ago than it should be. What
is below is history: it is not what this machine would do now.</p>
</div>
{{end}}
{{if .PastDropped}}
<p class="sub">{{.PastDropped}} older entries have been dropped to bound the file.
The oldest go first.</p>
{{end}}
{{if .Past}}
{{/* "Vouched for since" is read out of the mark registry, so a registry this
machine could not load cannot produce that number — and a 0 there is the
same reassuring lie the banner at the top exists to stop. The rows below
still render; each one says it cannot be judged. */}}
{{if .RegistryError}}
<div class="state bad">
<h2>{{.PastTotal}} would have been stopped. How many were mistakes is not known.</h2>
<p>Answering that needs the record of what you have marked as yours, which this
machine could not read. The rows below are what would have been stopped; what
became of each one is not something this page can say right now.</p>
</div>
{{else if .WalkError}}
{{/* The vouched count is read from the registry and is real; "still a
candidate right now" is read from the walk, which did not happen. */}}
<div class="state {{if .PastVouched}}warn{{else}}ok{{end}}">
<h2>{{.PastTotal}} would have been stopped. Vouched for since: {{.PastVouched}}.</h2>
<p>Every one in that second number is a program this rule would have taken away
from you by mistake, and that number is what decides whether it should ever be
allowed to act. Still a candidate right now: not known — this machine could
not read its processes just now. If one of these is yours, say so on the
<a href="/agents">automated programs page</a> — this rule reads the same record.</p>
</div>
{{else}}
<div class="state {{if .PastVouched}}warn{{else}}ok{{end}}">
<h2>{{.PastTotal}} would have been stopped. Vouched for since: {{.PastVouched}}.</h2>
<p>Every one in that second number is a program this rule would have taken away
from you by mistake, and that number is what decides whether it should ever be
allowed to act. Still a candidate right now: {{.PastStill}}. If one of these is
yours, say so on the <a href="/agents">automated programs page</a> — this rule
reads the same record.</p>
</div>
{{end}}
{{range .Past}}
<div class="obs past {{if .Still}}bare{{else if .Vouched}}vouched{{else if .Unknowable}}unknown{{else}}dormant{{end}}">
<div class="obs-head">
<span class="obs-name">{{.What}}</span>
{{if .Still}}<span class="tag warn">still a candidate</span>
{{else if .Vouched}}<span class="tag ok">vouched for since</span>
{{else}}<span class="tag kind">{{.Status}}</span>{{end}}
</div>
<p class="obs-why">{{.Status}}{{if .Because}} &mdash; {{.Because}}{{end}}</p>
<dl class="host-facts">
<dt>First proposed</dt><dd>{{.FirstSeen}}</dd>
<dt>Last proposed</dt><dd>{{.LastSeen}}</dd>
{{if .Origin}}<dt>Started</dt><dd>{{.Origin}}</dd>{{end}}
{{if .Cmdline}}<dt>Looked like</dt><dd><code>{{.Cmdline}}</code></dd>{{end}}
{{if .Endpoints}}
<dt>Was talking to</dt>
<dd>{{range $i, $e := .Endpoints}}{{if $i}}, {{end}}<code>{{$e}}</code>{{end}}</dd>
{{end}}
</dl>
</div>
{{end}}
{{else}}
<p class="sub">Nothing has been proposed for stopping in that time. This machine
looked and found nothing, which is a different sentence from the one above it.</p>
{{end}}
{{end}}
<p class="sub checked-at">Checked {{.CheckedAt}}. What this machine can see is
deliberately soft: a program that does not want to be noticed can rename itself,
and a mark is a convenience, not a lock. This rule inherits every one of those
limits.</p>
{{end}}
+97
View File
@@ -0,0 +1,97 @@
/* The setup page's own classes. app.css carries everything shared — the panels,
the tags, the flash, the buttons — and this file adds only what the forms and
the QR need. Same voice: plain, literal, no framework, served from the binary. */
.setup-form { display: grid; gap: 0.45rem; margin-top: 0.8rem; }
.setup-form label { color: var(--dim); font-size: 0.8rem; }
.setup-form button { justify-self: start; margin-top: 0.35rem; }
.setup-form input[type="text"],
.setup-form select {
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--bg);
color: var(--ink);
font: inherit;
font-size: 0.9rem;
}
.setup-form input.wide { width: 100%; }
.setup-form input[type="text"]:focus,
.setup-form select:focus { outline: 2px solid var(--ok); outline-offset: 1px; }
/* A tick or a radio and its words are one thing to click, so they are one
element — the label IS the target, which is what makes it reachable by
keyboard without any script. */
.tick { display: flex; align-items: center; gap: 0.45rem; color: var(--ink);
font-size: 0.86rem; cursor: pointer; }
.setup-hosts { margin-top: 0.8rem; border-top: 1px solid var(--line); padding-top: 0.7rem; }
/* ── the QR ────────────────────────────────────────────────────────────────
A QR is a grid of hard-edged squares. A browser smoothing it while it scales
is the thing that makes a code on a screen refuse to scan — the screen
version of the inverted-colours failure stepup_qr.go describes for a
terminal. `pixelated` keeps every module square at any size, and the white
background is set here rather than inherited because the page is dark and a
dark QR on dark is not a QR. */
.qr-wrap { display: flex; gap: 1.2rem; flex-wrap: wrap; margin-top: 0.9rem; }
.qr {
image-rendering: pixelated;
background: #fff;
border-radius: 4px;
max-width: 100%;
height: auto;
flex: 0 0 auto;
}
.qr-side { flex: 1 1 16rem; display: grid; gap: 0.6rem; align-content: start; }
.qr-side p { margin: 0; font-size: 0.88rem; }
.qr-side form { display: flex; gap: 0.4rem; margin: 0; flex-wrap: wrap; }
.secret-key {
margin: 0.3rem 0;
padding: 0.5rem 0.7rem;
border: 1px dashed var(--warn);
border-radius: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
letter-spacing: 0.06em;
word-break: break-all;
user-select: all;
}
/* ── the live path check ───────────────────────────────────────────────────
Colour alone never carries the answer: the sentence says what is wrong, and
the colour only agrees with it. */
.check { margin: 0.1rem 0 0.3rem; font-size: 0.84rem; }
.check.ok { color: var(--ok); }
.check.warn { color: var(--warn); }
.check.bad { color: var(--bad); }
.check .resolved {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--ink);
word-break: break-all;
}
/* ── a command the person runs themselves ────────────────────────────────── */
.cmd {
margin-top: 0.8rem;
padding: 0.7rem 0.9rem;
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 6px;
background: var(--bg);
}
.cmd.ok { border-left-color: var(--ok); }
.cmd.warn { border-left-color: var(--warn); }
.cmd.bad { border-left-color: var(--bad); }
.cmd-line { margin: 0 0 0.4rem; }
/* select-all on click: the whole point of this block is that it gets copied,
and a half-copied sudo line is a worse outcome than no line at all. */
.cmd-line code { user-select: all; word-break: break-all; }
.cmd .sub { margin-top: 0.3rem; }
header .sub a { color: var(--dim); }
+287
View File
@@ -0,0 +1,287 @@
{{define "setup-page"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — set this machine up</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/setup.css">
<!-- htmx's own indicator stylesheet is injected with insertAdjacentHTML and is
blocked by this page's CSP (default-src 'self', no style-src escape, no
nonce). Turn the injection off rather than widen the policy — see
page.html for the full reasoning. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>Set this machine up</h1>
<p class="sub">Pairing, a second factor, folders, the anchor and the owner name.
<a href="/">Back to what this machine is doing</a>.</p>
</header>
<main id="setup">
{{template "setup" .}}
</main>
<footer>
privatae-bastion {{.Version}} · this page is served from this machine only and
is not reachable from anywhere else.
</footer>
</body>
</html>
{{end}}
{{define "setup"}}
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{if .ReadError}}
<div class="flash bad">This machine's pairing records cannot be read: {{.ReadError}}.
The panels below cannot be trusted until that is fixed.</div>
{{end}}
{{/* The state first, because every panel below reads differently depending on
it — an anchor on a local box means nothing, and a folder needs a host. */}}
<div class="host">
<dl class="host-facts">
<dt>Mode</dt><dd>{{.Mode}}</dd>
<dt>Anchor</dt>
{{if .AnchorHost}}<dd>{{.AnchorHost}}{{if .AnchorOwner}} (owner “{{.AnchorOwner}}”){{end}}</dd>
{{else}}<dd>none{{if .AnchorRequired}} <span class="tag bad">required</span>{{end}}</dd>{{end}}
<dt>Owner</dt><dd>{{if .Owner}}{{.Owner}}{{else}}not claimed{{end}}</dd>
</dl>
{{if .Hosts}}
<dl class="host-facts setup-hosts">
{{range .Hosts}}
<dt>{{.Name}}</dt>
<dd>
{{.State}}
{{if .IsAnchor}}<span class="tag ok">anchor</span>{{end}}
{{if .Active}}
{{if .Serving}}<span class="tag ok">serving</span>
{{else}}<span class="tag bad">not answering</span>{{end}}
{{end}}
{{if .Stepup}}<span class="tag ok">second factor</span>
{{else}}<span class="tag warn">no second factor</span>{{end}}
</dd>
{{end}}
</dl>
{{end}}
</div>
{{/* ── 1. pair ───────────────────────────────────────────────────────────── */}}
<h3>Pair a host</h3>
<div class="host">
<p class="sub">The token comes from your host's “Set up a bastion”. It is used once.</p>
<form class="setup-form" hx-post="/setup/pair" hx-target="#setup" hx-swap="innerHTML"
autocomplete="off">
<label for="pair-token">Token</label>
<input id="pair-token" class="wide" name="token" type="text" autocomplete="off"
spellcheck="false" placeholder="skb_…">
<label class="tick">
<input type="checkbox" name="replace" value="1">
This host is already paired here — rotate its credentials
</label>
<button class="go" type="submit">Pair</button>
</form>
<div class="note">
Pairing adds a host; it never replaces one. On an anchored machine a new host
is written <strong>pending</strong> and starts nothing until the anchor host
approves it. Nothing here needs the bastion restarted.
</div>
</div>
{{/* ── 2. second factor ──────────────────────────────────────────────────── */}}
<h3>A second factor on this machine</h3>
<div class="host">
<p class="sub">Once a factor is enrolled, your host can only reach a folder or move
files here after you approve the request on this machine. Your host never sees the code.</p>
{{if .Enrol}}
<div class="qr-wrap">
{{/* The handle addresses the enrolment on the server. The secret is never
in this page: it is in the pixels. */}}
<img class="qr" src="/setup/stepup/qr?e={{.Enrol.Handle}}" width="320" height="320"
alt="QR code for the second factor being enrolled on this machine">
<div class="qr-side">
<p>Point your authenticator app at the square. It will appear as
<strong>Privatae NAC</strong>, for {{.Enrol.Host}}.</p>
<form hx-post="/setup/stepup/confirm" hx-target="#setup" hx-swap="innerHTML"
autocomplete="off">
<input type="hidden" name="enrol" value="{{.Enrol.Handle}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*"
autocomplete="one-time-code" placeholder="6-digit code"
aria-label="A code from the app you just scanned with">
<button class="go" type="submit">Confirm</button>
</form>
<p class="sub">Nothing is written to this machine until that code matches.</p>
<form hx-post="/setup/stepup/key" hx-target="#stepup-key" hx-swap="innerHTML">
<input type="hidden" name="enrol" value="{{.Enrol.Handle}}">
<button type="submit">Cannot scan? Show the key</button>
</form>
<div id="stepup-key"></div>
</div>
</div>
{{else if .Servable}}
{{/* .Servable, not .Hosts: a pending pairing gets no state on this machine
at all, not even a directory (run.go:311), and a factor for it would
gate ops it has no lanes to run. */}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<label for="enrol-host">For host</label>
<select id="enrol-host" name="host">
{{range .Servable}}<option value="{{.Name}}">{{.Name}}{{if .Stepup}} (already enrolled){{end}}</option>{{end}}
</select>
<label class="tick">
<input type="checkbox" name="replace" value="1">
Replace the factor already enrolled for that host
</label>
<button class="go" type="submit">Enrol a second factor</button>
</form>
<div class="note">
Replacing a factor stops the authenticator enrolled now from working.
</div>
{{else if .Hosts}}
<p class="sub">No host here has its lanes open, and a second factor gates ops a
pairing without lanes cannot run. Nothing is created for a pending host on this
machine until the anchor host approves it.</p>
{{else}}
<p class="sub">Pair a host first — a factor is enrolled per host.</p>
{{end}}
</div>
{{/* ── 3. folders ────────────────────────────────────────────────────────── */}}
<h3>Add a folder to your network</h3>
<div class="host">
{{if .Servable}}
<p class="sub">Your files stay on this machine. Your host keeps only the index, and
the draft does nothing until you approve it there.</p>
<form class="setup-form" hx-post="/setup/folder/propose" hx-target="#setup" hx-swap="innerHTML"
autocomplete="off">
<label for="folder-path">Folder</label>
{{/* A browser cannot open a native folder picker without the user choosing a
folder in it, and what it hands back is a file list, not a path this
machine can name. So: a typed path, checked against the real filesystem
as it is typed. */}}
<input id="folder-path" class="wide" name="path" type="text" autocomplete="off"
spellcheck="false" placeholder="/home/you/Documents"
hx-post="/setup/folder/check" hx-trigger="keyup changed delay:400ms, change"
hx-target="#folder-check" hx-swap="innerHTML">
<div id="folder-check">{{if .Check}}{{template "folder-check" .}}{{end}}</div>
<label for="folder-name">Shown as (optional)</label>
<input id="folder-name" class="wide" name="name" type="text" autocomplete="off"
placeholder="Documents">
{{if gt (len .Servable) 1}}
<label for="folder-host">Propose to</label>
<select id="folder-host" name="host">
{{range .Servable}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
{{else}}
{{range .Servable}}<input type="hidden" name="host" value="{{.Name}}">
<p class="sub">Proposed to {{.Name}}.</p>{{end}}
{{end}}
<button class="go" type="submit">Propose this folder</button>
</form>
{{else}}
<p class="sub">No host on this machine has its lanes open, so there is nothing to
propose a folder to yet.</p>
{{end}}
</div>
{{/* ── 4. the anchor ─────────────────────────────────────────────────────── */}}
<h3>Which host holds the anchor</h3>
<div class="host">
<p class="sub">The anchor host is the one that approves every other host paired here.
It is a choice among the hosts this machine already serves.</p>
{{if .Hosts}}
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
{{range .Hosts}}
<label class="tick">
<input type="radio" name="host" value="{{.Name}}">
{{.Name}}
{{if .IsAnchor}}<span class="tag ok">anchor</span>{{end}}
{{/* A pending host CAN take the anchor — claim-anchor flips it active,
because the anchor never waits on itself (anchor.go:931). Said here
so the state is visible at the moment of choosing, not only above. */}}
{{if eq .State "pending"}}<span class="tag warn">waiting for approval</span>{{end}}
{{if eq .State "revoked"}}<span class="tag bad">revoked</span>{{end}}
</label>
{{end}}
<button type="submit">Show me the line to run</button>
</form>
{{else}}
<p class="sub">Pair a host first — the anchor is one of them.</p>
{{end}}
<div id="anchor-cmd">{{if .Cmd}}{{template "anchor-cmd" .}}{{end}}</div>
<div class="note">
<strong>This page will not do it.</strong> Claiming the anchor needs machine
authority — root on this box is what stands behind it — and it decides who
approves every pairing made here from now on. A page you are signed into for
twelve hours is not the right thing to hang that on, so the choice is here
and the act stays at a terminal.
</div>
</div>
{{/* ── 5. the owner name ─────────────────────────────────────────────────── */}}
<h3>Who owns this machine</h3>
<div class="host">
<p class="sub">A plain name, shown on this machine's card on every host it serves.
It is optional and it grants nothing.</p>
<form class="setup-form" hx-post="/setup/owner/name" hx-target="#owner-cmd" hx-swap="innerHTML"
autocomplete="off">
<label for="owner-name">Owner</label>
<input id="owner-name" class="wide" name="owner" type="text" autocomplete="off"
placeholder="{{if .Owner}}{{.Owner}}{{else}}a person or a team{{end}}">
<button type="submit">Show me the line to run</button>
</form>
<div id="owner-cmd">{{if .Cmd}}{{template "owner-cmd" .}}{{end}}</div>
<div class="note">
Root-gated for the same reason as the anchor: there is no authority above this
box to ask, so the ability to run root here is what the name stands on.
</div>
</div>
{{end}}
{{/* ── fragments swapped on their own, so typing does not lose focus ─────── */}}
{{define "folder-check"}}
{{with .Check}}
<p class="check {{.Class}}">
{{.Line}}
{{if .Resolved}}<br><span class="resolved">{{.Resolved}}</span>{{end}}
{{if .Note}}<br><span class="sub">{{.Note}}</span>{{end}}
</p>
{{end}}
{{end}}
{{define "stepup-key"}}
{{if and .Enrol .Enrol.Key}}
<p class="sub">Enter this key by hand, then type a code from the app:</p>
<p class="secret-key">{{.Enrol.Key}}</p>
<p class="sub">It is a secret. It is on screen because you asked for it, and it is
gone from this page as soon as you reload.</p>
{{else}}
<p class="sub">That enrolment has expired — start it again.</p>
{{end}}
{{end}}
{{define "anchor-cmd"}}
{{with .Cmd}}
<div class="cmd {{.Class}}">
{{if .Command}}<p class="cmd-line"><code>{{.Command}}</code></p>{{end}}
{{if .How}}<p class="sub">{{.How}}</p>{{end}}
{{if .Why}}<p class="sub">{{.Why}}</p>{{end}}
</div>
{{end}}
{{end}}
{{define "owner-cmd"}}
{{with .Cmd}}
<div class="cmd {{.Class}}">
{{if .Command}}<p class="cmd-line"><code>{{.Command}}</code></p>{{end}}
{{if .How}}<p class="sub">{{.How}}</p>{{end}}
{{if .Why}}<p class="sub">{{.Why}}</p>{{end}}
</div>
{{end}}
{{end}}
+170
View File
@@ -0,0 +1,170 @@
{{define "status"}}
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{/* Approvals come FIRST and before anything else on the page. They are the
only thing here that is waiting on the person, and everything below is
still true in a minute. */}}
{{/* Asks waiting on THIS machine as your approval device (uidevice.go). They
are answered in the approvals popup, which the tray and the notification
open; the front page says they are there and links to it. */}}
{{if .DeviceApprovals}}
<div class="approval">
<div class="approval-head">{{len .DeviceApprovals}} request{{if gt (len .DeviceApprovals) 1}}s{{end}} from your hosts {{if gt (len .DeviceApprovals) 1}}are{{else}}is{{end}} waiting for your approval</div>
<p class="approval-line"><a href="/approvals">Open approvals</a> to read and answer {{if gt (len .DeviceApprovals) 1}}them{{else}}it{{end}}.</p>
</div>
{{end}}
{{if .Approvals}}
<h3 class="urgent">Waiting for you</h3>
{{template "stepup-approvals" .}}
{{end}}
{{/* WHAT HAS HAPPENED HERE — above the state block on purpose. The template's
own rule for the approvals is that everything below them "is still true in
a minute"; the state block is exactly that, and a host having killed the
programs you were running is not. It is news, it is the only thing on this
page the user neither did nor was asked about, and nobody would think to
scroll for it. */}}
{{if .Refusal}}
<h3 class="urgent">This machine refused something your host asked for</h3>
<div class="event refused">
<div class="event-when">{{.Refusal.At}}</div>
<p class="event-line">Refused: <strong>{{.Refusal.What}}</strong></p>
<p class="event-line">{{.Refusal.Why}}</p>
<p class="event-foot">Your host is told this on its next check-in, so it is not
left waiting on something that is never going to happen.</p>
</div>
{{end}}
{{if .Events}}
<h3 {{if .EventsRecent}}class="urgent"{{end}}>What your host has done on this machine</h3>
{{range .Events}}
<div class="event{{if .Recent}} recent{{end}}">
<div class="event-when">{{.At}}</div>
<p class="event-line">{{.Text}}</p>
</div>
{{end}}
{{end}}
<div class="state {{.StateClass}}">
<h2>{{.State}}</h2>
{{if .StateNote}}<p>{{.StateNote}}</p>{{end}}
</div>
<h3>Hosts this machine serves</h3>
{{if not .Pairings}}
<div class="host">
<div class="host-name">No host is paired yet.</div>
<p class="sub">Pair one with a token from your host's “Set up a bastion”.</p>
</div>
{{end}}
{{range .Pairings}}
<div class="host">
<div class="host-name">
{{.Host}}
{{if .IsAnchor}}<span class="tag ok">anchor</span>{{end}}
{{if eq .State "pending"}}<span class="tag warn">waiting for approval</span>{{end}}
{{if eq .State "revoked"}}<span class="tag bad">revoked</span>{{end}}
</div>
<dl class="host-facts">
<dt>Local port</dt>
<dd>{{.ListenAddr}}
{{if .Listening}}<span class="tag ok">answering</span>
{{else}}<span class="tag bad">not answering</span>{{end}}</dd>
{{if .TunnelIP}}<dt>Tunnel address</dt><dd>{{.TunnelIP}}</dd>{{end}}
{{if .FirstSeen}}<dt>Paired since</dt><dd>{{.FirstSeen}}</dd>{{end}}
</dl>
</div>
{{end}}
<h3>This machine</h3>
<div class="host">
<dl class="host-facts">
<dt>Mode</dt>
<dd>{{.Mode}}{{if .ModeNote}} — {{.ModeNote}}{{end}}</dd>
<dt>Anchor</dt>
{{if .AnchorHost}}
<dd>{{.AnchorHost}}{{if .AnchorOwner}} (owner “{{.AnchorOwner}}”){{end}}</dd>
{{else}}
<dd>none{{if .AnchorRequired}} <span class="tag bad">required</span>{{end}}</dd>
{{end}}
<dt>Owner</dt>
<dd>{{if .Owner}}{{.Owner}}{{else}}not claimed{{end}}</dd>
<dt>Version</dt>
<dd>{{.Version}}{{if .UpdateAvailable}}
<span class="tag warn">{{.UpdateAvailable}} available</span>{{end}}</dd>
</dl>
{{if .AnchorRequired}}
<div class="note">
This machine is <strong>networked</strong> but not anchored, so it answers
nothing: no host may run an op, open a terminal or place anything here until
one of them is made the anchor. Run
<code>sudo -H privatae-bastion claim-anchor</code> on this machine.
</div>
{{end}}
{{if .UpdateAvailable}}
{{/* Says where the control IS, because it is not here and must not be. The
bastion does not update itself — a host directs it (update.go) — and a
local UI that grew an Update button would be the first step across the
line in ui.go's header. Naming a version without naming who installs it
sends someone looking for a button this page will never have. */}}
<div class="note">
Version <strong>{{.UpdateAvailable}}</strong> has been published. This
machine will not install it on its own: your host directs updates. Open the
<strong>Network</strong> app on your host, find this machine under
<strong>Bastions</strong>, and use <strong>Update</strong> there. Nothing
changes here until it does.
</div>
{{end}}
{{if .NoConsoleNote}}
<div class="note">
After it updates itself the bastion restarts with no console window, which is
why nothing appears on screen while it runs. Task&nbsp;Manager →
<code>privatae-bastion.exe</code> is where it lives.
</div>
{{end}}
</div>
<p class="sub checked-at">Checked {{.CheckedAt}}.</p>
{{end}}
{{define "stepup-approvals"}}
{{range .Approvals}}
<div class="approval{{if .Unreadable}} unreadable{{end}}">
<div class="approval-head">{{.Host}} is asking <span class="id">{{.Short}}</span></div>
{{if .Unreadable}}
<p class="approval-line"><strong>This machine cannot tell you what is being asked for.</strong></p>
<p class="approval-line">It does not recognise this kind of request, which usually means your
host is newer than the bastion here. Deny it and update this machine —
approving something neither of you can read is not worth the risk.</p>
{{else}}
{{range .Lines}}<p class="approval-line">{{.}}</p>{{end}}
{{end}}
{{/* When the request cannot be described, Deny leads and Approve is demoted:
the green button must never be the easy one next to text that says
nothing. */}}
<div class="approval-acts{{if .Unreadable}} reversed{{end}}">
<form hx-post="/deny" hx-target="{{if $.ApprovalsView}}#approvals{{else}}#status{{end}}" hx-swap="innerHTML">
<input type="hidden" name="host" value="{{.Host}}">
{{if $.ApprovalsView}}<input type="hidden" name="view" value="approvals">{{end}}
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="purpose" value="{{.PurposeSHA256}}">
<button class="{{if .Unreadable}}go{{else}}no{{end}}" type="submit">Deny</button>
</form>
<form hx-post="/approve" hx-target="{{if $.ApprovalsView}}#approvals{{else}}#status{{end}}" hx-swap="innerHTML" autocomplete="off">
<input type="hidden" name="host" value="{{.Host}}">
{{if $.ApprovalsView}}<input type="hidden" name="view" value="approvals">{{end}}
<input type="hidden" name="id" value="{{.ID}}">
{{/* What these words describe, so the tray's lane approves exactly the
request that was read (approvalpipe.go). */}}
<input type="hidden" name="purpose" value="{{.PurposeSHA256}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*"
autocomplete="one-time-code" placeholder="6-digit code"
aria-label="Code from the authenticator enrolled on this machine">
<button class="{{if .Unreadable}}no{{else}}go{{end}}" type="submit">Approve</button>
</form>
</div>
<p class="approval-foot">The code comes from the authenticator you enrolled on
this machine. Your host never sees it.</p>
</div>
{{end}}
{{end}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB