bastion 0.53.2
This commit is contained in:
@@ -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); }
|
||||
@@ -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="/">← 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}} · <a href="/">this machine</a> · 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}} · {{.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 “This one is
|
||||
mine”.</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}}
|
||||
@@ -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); }
|
||||
@@ -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}}
|
||||
@@ -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; }
|
||||
@@ -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}} · your context, from your files · 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 · 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}} · {{.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}}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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="/">← 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}} · <a href="/">this machine</a> ·
|
||||
<a href="/given">what this machine has given away</a> · 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 · 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}} · {{.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}}
|
||||
@@ -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; }
|
||||
@@ -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="/">← 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}} · <a href="/">this machine</a> · 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 <host></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}} · <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}}
|
||||
@@ -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.
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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> ·
|
||||
<a href="/setup">Set this machine up</a> ·
|
||||
<a href="/given">What this machine has given away</a> ·
|
||||
<a href="/diskmap">This machine's disk</a> ·
|
||||
<a href="/agents">Automated programs running here</a> ·
|
||||
<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>
|
||||
@@ -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); }
|
||||
@@ -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="/">← This machine</a> ·
|
||||
<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}} · <a href="/">this machine</a> · 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}} · {{.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}} — {{.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}}
|
||||
@@ -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); }
|
||||
@@ -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}}
|
||||
@@ -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 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 |
Reference in New Issue
Block a user