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

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+681
View File
@@ -0,0 +1,681 @@
package main
// uigiven.go — WHAT THIS MACHINE HAS GIVEN AWAY.
//
// WHY IT EXISTS. The local UI's design names the same gap twice, and it is
// the most uncomfortable fact in it. A
// host paired here can read the title of every window open on this screen and
// which one has focus (computer:windows), walk the controls inside one app
// (computer:tree), read the files in the folders it was granted (the thirteen
// dcn_* / ctx_* / backup_fetch ops, mgmt.go:103-115) and open a ROOT shell
// (session:root, session.go:127) — and the machine gives the person sitting at
// it no local way to see any of it. `dcn_list` answers the HOST. `pairing_list`
// is answered only on the anchor host's own lane (mgmt.go:224-230). There has
// been no verb here that answers "what did I agree to".
//
// Two views close it: the CEILING (which host may ask for what) and the
// FOLDERS (what it can actually reach). They are two views and not one because
// they are two different walls — holding the `dcn` family only means the caps
// ceiling will not refuse a dcn op; every individual read still needs a signed
// grant for that store (dcn.go:240-278). A page that showed only the ceiling
// would overstate, and one that showed only the folders would miss the shell.
//
// BOTH ARE READ-ONLY BY DECISION, NOT BY OMISSION. Whether the local UI gets an
// OFF switch for a granted capability is a product decision and is not this
// page's to take. So there is no toggle here, and the page is deliberately not
// shaped like a settings screen with the switches missing — no empty action
// column, no disabled controls. It states what is true and names the two things
// that DO end a grant today: removing the folder on your Host, which drops the
// grant here (dcn_grant_drop, mgmt.go:109), and `privatae-bastion unpair
// <host>` (main.go:193).
//
// THE MATRIX ASKS THE REAL GATE. Every "granted / refused" on this page is
// capsAllow(host, op) — the same function handleMgmtConnFor (mgmt.go:238),
// handleHarnessConn (harness.go:116), the egress lane (egress.go:57), the
// inference forwards (run.go:856) and sessionAllowedByCaps (session.go:214) ask
// before they serve. The rejected alternative was to re-derive the answer here
// from anchor.json plus the grant file, which is every branch of capsAllow
// (anchor.go — five of them now the local switch is inside it) copied into a
// template — and the first time one of them changed, this page would go on
// confidently saying what used to be true. A view that re-implements its
// subject is a second wall that drifts. This one cannot disagree with the gate,
// because it asks the gate.
//
// AND THE SWITCHED-OFF TAG HAS TO COME FROM THE GATE TOO, which is the half
// that was missing. `Held` asks capsAllow; the tag reads the deny list. On an
// unanchored box the gate did not consult that list, so the two sources gave
// opposite answers about the same capability on the most security-relevant page
// in the binary — the page said "switched off by you" and the machine served
// the op. Fixed in the gate rather than the page (anchor.go), because the page
// being right about a gate that is wrong is not the property this paragraph
// claims.
import (
"encoding/base64"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
)
// ── the ceiling ─────────────────────────────────────────────────────────────
// uiCapFamily is one of allCaps (anchor.go:445), said in the words of what the
// host can actually do to this machine with it.
type uiCapFamily struct {
Name string
// Probe is an op in this family. It exists so the page ASKS capsAllow
// rather than reasoning about it. Asserted against opFamily in the test:
// a rename that moved an op out of its family would otherwise leave this
// page probing the wrong gate and answering confidently.
Probe string
// Line is the whole capability in one sentence — what a person would say
// they agreed to. Detail is the specifics.
Line string
Detail string
// Second is the wall BEYOND this ceiling, where one exists. Naming it
// matters in both directions: without it the page overstates three
// families, and a page that overstates gets discounted wholesale.
Second string
// Grave marks the three the design singles out — the shell, the files
// and the screen. Nothing about the page's behaviour turns on it; it is
// what the copy leads with.
Grave bool
// Filled per pairing.
Held bool
// SwitchedOff is the person at this machine having turned this family off
// (localdeny.go). It is independent of Held: a family can be GRANTED by the
// host and OFF here, which is the entire point of the control — the grant
// is untouched and the machine refuses anyway.
SwitchedOff bool
Ops []string
}
// uiGivenFamilies is allCaps in a deliberate order: the root shell, then the
// files, then the screen, then the rest. A list a person reads top-down should
// lead with what would frighten them, not with "egress".
//
// The set is asserted equal to allCaps in the test, so a family added to the
// ceiling cannot arrive on this page undescribed — which is the failure mode
// that matters here. A capability the machine gives away and does not mention
// is worse than no page at all: the page would be the reason nobody looked.
var uiGivenFamilies = []uiCapFamily{
{
Name: "session", Probe: sessionOpOpen, Grave: true,
Line: "Open a shell on this machine.",
Detail: "session:open runs a terminal here as an unprivileged user when the bastion " +
"runs as root (a dedicated session account if one exists, else nobody), and as the " +
"bastion's own user otherwise. session:root runs as root. Both are real shells, " +
"streamed to the host over the tunnel this machine dialled out.",
Second: "A shell is your own act in your Terminal app, and needs no standing grant from " +
"the host: on a machine anchored to a host, opening one takes your approval on your " +
"device or here, bound to that one session. A root shell needs three more things: the " +
"bastion must be running as root, a second factor must be enrolled on this machine, " +
"and you must approve that exact session here with your code. The host never sees the code.",
},
{
Name: "dcn", Probe: "dcn_list", Grave: true,
Line: "Read your files.",
Detail: "List and read the files in the folders below, search them, and build an index " +
"of them. The same family also lets the host write into this machine's own store — " +
"ctx_put, ctx_del and backup_fetch land bytes in the bastion's private directory, " +
"not in your folders.",
Second: "Which of your files, exactly, is the second list on this page. This family only " +
"means a request will not be refused for being the wrong KIND; every individual " +
"read is still checked against a signed grant for that one folder.",
},
{
Name: "observe", Probe: "observe:host", Grave: true,
Line: "Read this machine, and what is on its screen.",
Detail: "The machine itself: its firewall text, its listening sockets, its disks, its " +
"kernel log, which services failed, a network probe from here, and — where a " +
"read-only kubeconfig was placed here at install — the clusters it can reach. " +
"And the screen in front of you: the title of every window open right now, which " +
"application owns each one and which has focus, plus the names, text and state of " +
"the controls inside one application. Text rather than pixels, which does not " +
"make it less of a read.",
Second: "Nothing. On this machine the grant is the only wall in front of these answers.",
},
{
Name: "models", Probe: "model:list",
Line: "Change which models sit on this machine's disk.",
Detail: "Pull a model onto it — gigabytes — list what is there, and delete one.",
Second: "A host can only delete a model it placed here itself. One that was already on " +
"the machine, or that another host put there, is not its to drop.",
},
{
Name: "harness", Probe: "harness:run",
Line: "Run work on this machine.",
Detail: "Harness jobs: the host dispatches a job over the tunnel and it executes here.",
},
{
Name: "egress", Probe: "egress",
Line: "Send traffic out through this machine's internet connection.",
Detail: "Requests leave from here, over this connection, carrying this machine's " +
"address rather than the host's.",
},
{
Name: "services", Probe: "service:up",
Line: "Run a server on this machine that stays up.",
Detail: "A server your host built to your spec — a private code server, say — kept running " +
"in a container here across restarts, with its data in the bastion's own directory. " +
"It runs with NO network: it cannot reach this machine, your network or the internet. " +
"The one way in is the host's tunnel, one port per server (18100-18199), spliced to " +
"the server's socket. The host can start one, stop one and read their state.",
Second: "Each start and each stop needs your approval on an anchored machine; reading " +
"the list does not. Stopping a server keeps its data unless the request says to " +
"delete it. Reaching a server needs more than this: a reach grant naming that host and that " +
"server, which you approve, checked on every connection. A member host cannot replace or " +
"stop a server another host built.",
},
{
Name: "ops", Probe: "op_apply",
Line: "Run programs the host wrote on this machine.",
Detail: "An op is a program your host built and tested, sent here with signed terms and " +
"held under them. It dry-runs in a sandbox first (an overlay over a read-only view of " +
"this machine, no network) and the card shows exactly the files it would change; " +
"applying commits that same run. Rung 6: file changes only, on Linux.",
Second: "Every apply spends your approval (device or console), bound to the op's exact " +
"bytes, arguments and the change list you saw; a standing grant is 'user' only, bounded " +
"and counted, and `privatae-bastion ops drop` takes it back here. The dry run itself runs " +
"before you are asked, as root for a root op: it can change nothing on this machine, but " +
"what it reads and prints goes back to the host. This machine's own record (its keys, " +
"grants and ledgers) is never an effect and never a change.",
},
{
Name: "inference", Probe: "inference",
Line: "Use the model servers already running here.",
Detail: "The three inbound forwards the host dials over its own tunnel: ollama on " +
"11434, the OpenAI-compatible server on 18080 and the PII sterilizer on 18081. " +
"The right to use what is already loaded. Changing WHICH models are loaded is " +
"the models capability above, and it is deliberately not the same grant.",
},
}
// uiFamilyOps groups this binary's REAL op vocabulary by family, so the list
// under each heading is what this build will actually answer rather than a list
// somebody remembered to update.
//
// mgmtOpVocabulary (enforcing.go:67) is handlerOps + sessionOps + p8AllowedOps
// — every op that reaches handleMgmtConnFor. The three LANE gates are NOT in
// it: harness:run (harness.go:116), egress (egress.go:57) and inference
// (run.go:856) are passed to capsAllow at their own lane and never arrive as a
// mgmt job, so a list built from the vocabulary alone would show three families
// with nothing under them, which reads as "this grants nothing".
func uiFamilyOps() map[string][]string {
ops := append(mgmtOpVocabulary(), "harness:run", "egress", "inference",
serviceGateOp("up"), serviceGateOp("down"), serviceGateOp("status"), serviceCapability)
out := map[string][]string{}
for _, op := range ops {
if fam := opFamily(op); fam != "" {
out[fam] = append(out[fam], op)
}
}
for _, v := range out {
sort.Strings(v)
}
return out
}
// ── the folders ─────────────────────────────────────────────────────────────
// uiSharedFolder is ONE entry of grants.json's Stores map (dcn.go:47-63),
// rendered as what it really contains rather than what a grant is assumed to
// hold. There is no folder NAME in a machine store grant and no label — the
// signed terms carry a root path, a mask, excludes and an expiry (dcn.go:66-83)
// — so this shows the path and says plainly when a field is absent instead of
// printing a default nobody signed.
type uiSharedFolder struct {
StoreID string
// Readable is false when the record's canonical bytes do not decode or do
// not parse. Then only StoreID and the verdict below can be shown.
Readable bool
Root string
// Mask is the raw signed values; MaskWords is the same list said in plain
// words, computed HERE rather than in the template because html/template
// can only call functions registered on the template, and registering a
// FuncMap means reaching into newUIServer (ui.go:246), which this unit does
// not own. Both travel: a mask value this version cannot describe still has
// to appear exactly as it was signed.
Mask []string
MaskWords []string
Excludes []string
Expires string
Sweep string
TTLDays int
Class string
Sensitive bool
// Sealed: the record carries a seal recipient. Called out separately
// because it is the one field that travels OUTSIDE the signed bytes
// (dcn.go:59-62), so it is not part of what was approved.
Sealed bool
// InForce is the whole verdict: this folder is reachable by this host right
// now. Why carries the reason when it is not.
InForce bool
Why string
}
// uiFoldersFor answers "what of mine is shared with this host, from here".
//
// The verdict comes from dcnVerifyRec (dcn.go:130), the same function every
// dcn op re-runs before it touches a byte, plus the conditions dcnLoadGrantMask
// applies right after it: a machine-class grant with a root (dcn.go:264) whose
// mask names at least one operation (dcn.go:270). Nothing here re-implements
// the check. A SIGNATURE THAT VERIFIES IS NOT THE VERDICT — stopping there was
// the first cut and it showed two refused grants as live shares.
//
// dcnHeld IS PART OF THE VERDICT, and leaving it out was a real overstatement
// in the first cut of this page — caught by rendering it. The caps ceiling runs
// BEFORE any of that: capsAllow refuses dcn_list at mgmt.go:238 and the store
// grant is never consulted, so a host without the `dcn` family cannot read a
// folder however perfect the grant for it is. A page that showed that folder as
// "shared" would be frightening the user about something that cannot happen,
// which costs the page its credibility just as surely as missing a real one.
//
// The TERMS shown are decoded straight from the record's canonical bytes even
// when the verdict is a refusal, and that is deliberate. Showing only verified
// grants would make an expired or tampered record VANISH, which tells the user
// "nothing is shared" while a record sits on their disk — the misleading
// direction. So the bytes are shown with the verdict beside them, and an
// unverified record is labelled as granting nothing rather than dropped. The
// bytes are this machine's own 0600 file; anyone who can rewrite them can
// already do worse than put text on a page only they can open.
func uiFoldersFor(c *Config, dcnHeld bool) ([]uiSharedFolder, string) {
// A PENDING OR REVOKED PAIRING IS NOT ASKED — the same guard, for the same
// reason, as pendingApprovals (ui.go). dcnDir() is MkdirAll-on-read
// (config.go:115), so asking CREATED the directory, and run.go:311-318
// keeps every such call behind cfg.active() precisely so nothing leaks a
// side effect for a host the anchor has not approved.
//
// This page reintroduced the defect the rest of this change exists to fix,
// on the page whose own copy tells the user a pending pairing gets "no
// tunnel, no lanes, no directories". The original
// test could not catch it because the fixture pre-created the directory,
// which is why the new one asserts on a path that was never created rather
// than on rendered words.
if !c.active() {
return nil, "This pairing is not active, so nothing is shared with it and it holds no " +
"store directory on this machine."
}
dir := c.dcnDir()
if dir == "" {
return nil, "This host has no store directory on this machine, so no folder can be " +
"shared with it and every dcn op is refused."
}
f, err := dcnLoadFile(dir)
if err != nil {
return nil, "This machine's record of what it shared with this host cannot be read (" +
err.Error() + "). Nothing is being served from it while that is true: every read " +
"re-opens this file and refuses when it cannot."
}
if len(f.Stores) == 0 {
return nil, "No folder of yours is shared with this host from this machine."
}
// Numeric order, the same way the enforcing report sorts them
// (enforcing.go:112) — store 2 must not sit between 10 and 11.
ids := make([]string, 0, len(f.Stores))
for k := range f.Stores {
ids = append(ids, k)
}
sort.Slice(ids, func(i, j int) bool {
a, errA := strconv.ParseInt(ids[i], 10, 64)
b, errB := strconv.ParseInt(ids[j], 10, 64)
if errA != nil || errB != nil {
return ids[i] < ids[j]
}
return a < b
})
// No pinned key means nothing can be verified, and dcnLoadGrantMask refuses
// every op before it looks at a store (dcn.go:250). Said once, above the
// list, rather than repeated as a reason on every row.
note := ""
if f.VerifyPubkey == "" {
note = "No key from this host has been pinned on this machine yet, so none of these " +
"records can be verified and every read is refused."
}
// Deliberately REPLACES the pin note rather than joining it: the ceiling is
// the outer wall, so "no key is pinned" is true but beside the point, and two
// reasons for one outcome is how a user ends up fixing the wrong one.
if !dcnHeld {
note = "This host is refused every file operation on this machine, so it cannot " +
"read any of these whatever their grants say. They are listed because the " +
"records are here and a grant that stops being reachable does not stop existing."
}
out := make([]uiSharedFolder, 0, len(ids))
for _, k := range ids {
rec := f.Stores[k]
sf := uiSharedFolder{StoreID: k, Sealed: rec.SealRecipient != ""}
if t := uiDecodeDcnTerms(rec.TermsCanonical); t != nil {
sf.Readable = true
sf.Root, sf.Mask, sf.Excludes = t.Root, t.Mask, t.Excludes
sf.Expires, sf.Sweep, sf.TTLDays = t.Expires, t.Sweep, t.TTLDays
sf.Class, sf.Sensitive = t.Class, t.Sensitive
for _, m := range t.Mask {
sf.MaskWords = append(sf.MaskWords, uiMaskWords(m))
}
}
switch terms, err := dcnVerifyRec(f.VerifyPubkey, rec); {
case !dcnHeld:
// The ceiling, not the grant — said as the reason so a user who fixes
// the grant does not wonder why nothing changed.
sf.Why = "this host holds no file capability on this machine"
case err != nil:
sf.Why = err.Error()
case terms.Class != "machine" || terms.Root == "":
// dcn.go:264 refuses these after the signature verifies, so a page
// that stopped at "signature ok" would show a live share that is not.
sf.Why = "this grant is not for a folder on this machine"
case len(terms.Mask) == 0:
sf.Why = "this grant allows no operation at all"
default:
sf.InForce = true
}
out = append(out, sf)
}
return out, note
}
// uiDecodeDcnTerms decodes a record's canonical bytes for DISPLAY ONLY. It is
// the same two steps dcnVerifyRec takes after the signature checks out
// (dcn.go:135-149), which is why the two can never show different content —
// but it deliberately skips the signature, because a record that fails to
// verify is exactly the one the user most needs to see written out. nil when
// the bytes are not a grant at all.
func uiDecodeDcnTerms(canonicalB64 string) *dcnTerms {
raw, err := base64.StdEncoding.DecodeString(canonicalB64)
if err != nil {
return nil
}
var t dcnTerms
if err := json.Unmarshal(raw, &t); err != nil {
return nil
}
return &t
}
// uiMaskWords says what one mask value lets a host do, and passes an UNKNOWN
// value through verbatim rather than dropping it.
//
// The three this box consults are read (dcn.go:241), search (freeagent.go:300)
// and index (freeagent.go:360). A grant may legitimately carry a value this
// version has never heard of when the host is newer than the machine — the
// ordinary state of a fleet mid-upgrade — and rendering "read, search" for a
// grant that also said something else would be a lie by omission on the one
// page whose whole job is not to have any.
func uiMaskWords(m string) string {
switch m {
case "read":
return "read — list the files in it, and read what is in them"
case "search":
return "search — answer searches over it"
case "index":
return "index — build and hold an index of it"
}
return m + " — this version of the bastion does not know what this allows"
}
// ── one host ────────────────────────────────────────────────────────────────
type uiGivenHost struct {
Host string
State string
Served bool // active; run.go:194 starts nothing for anything else
IsAnchor bool
// Basis is WHY this host holds what it holds, in the words of the rule that
// decided it. The held/refused marks come from capsAllow; this explains
// them, and the test pins the two together so an explanation cannot survive
// a gate that stopped agreeing with it.
Basis string
Families []uiCapFamily
HeldCount int
Folders []uiSharedFolder
FolderNote string
}
type uiGiven struct {
Flash string
FlashClass string
// DenyNote is why the switched-off list could not be read, when it could
// not. Without it the page renders every capability as GRANTED while the
// person's switches are silently not in effect — showing them the opposite
// of the truth, which is the outcome the whole control exists to prevent.
DenyNote string
Version string
Mode string
Anchored bool
Hosts []uiGivenHost
CheckedAt string
// NoPairings is its own field rather than len(Hosts)==0 in the template,
// because "no host is paired" is the one genuinely reassuring state on this
// page and it deserves to be said, not inferred from an empty list.
NoPairings bool
// ReadError is why the pairing list could not be read. It exists because
// this page used to fold that error INTO NoPairings:
// an unparseable pairing record printed "No host is paired with this
// machine" — the most reassuring sentence on the box — about a machine
// whose records it had just failed to read. The status page already refuses
// to do that (ui.go:202-205); these two now match it.
ReadError string
}
// uiGivenBasis is the branch of capsAllow (anchor.go:528-553) that decided this
// host's answer, said to the person whose machine it is.
//
// It reads the same three things capsAllow reads — the anchor, the box mode and
// the verified grant — in the same order, and the test drives all five
// situations through the real gate and asserts the basis and the held set agree.
// It is an explanation, never the decision: nothing on this page is held or
// refused because of what this function returns.
func uiGivenBasis(host string, isAnchor bool) string {
a := currentAnchor()
switch {
case a == nil && boxMode() == modeNetworked:
return "This machine is networked but not anchored, so it serves nothing at all. " +
"No host may run an op, open a terminal or place anything here until one of " +
"them is made the anchor."
case a == nil:
return "This machine is not anchored, so the pairing itself is the authorization: " +
"pairing this host gave it everything below. That is what a local bastion is — " +
"one machine, one host — and the way to take it back is to unpair."
case isAnchor:
return "This is the anchor host, and the anchor is unconstrained. It holds every " +
"capability here, and it alone directs the three things that affect the whole " +
"machine: replacing this program, containing this machine, and placing a memory " +
"mirror on it."
}
if _, ok := pairingCaps(host); !ok {
return "This host holds no verified grant from the anchor, so everything is refused. " +
"A grant that was revoked, that expired, that names another machine or that was " +
"edited on disk all land here."
}
return "The anchor host signed a grant naming exactly what is marked granted below. " +
"Anything not named is refused on this machine, whatever the host asks for."
}
// collectGiven reads this machine's state once and answers both views.
//
// Re-read on every render rather than cached, for the same reason renderWith
// re-reads (ui.go:338-341): a grant that lands from the host while this page is
// open must appear, and a stale copy of a security page is worse than no page —
// it is the page the user trusted.
func collectGiven() uiGiven {
// READ ONCE, AND KEEP THE ERROR. The page used to ask localDenied() per
// family, which discards it — so an unreadable list rendered as "nothing is
// switched off" and the person saw their switches as not existing rather
// than as not working.
off, denyNote := localDenyIndex()
g := uiGiven{
DenyNote: denyNote,
Version: clientVersion,
Mode: boxMode(),
Anchored: currentAnchor() != nil,
CheckedAt: uiNow().Format("15:04:05"),
}
anchorHost := ""
if a := currentAnchor(); a != nil {
anchorHost = a.AnchorHost
}
cfgs, err := loadPairings()
if err != nil {
g.ReadError = err.Error()
return g
}
if len(cfgs) == 0 {
g.NoPairings = true
return g
}
byFamily := uiFamilyOps()
for _, c := range cfgs {
h := uiGivenHost{
Host: c.HostName,
State: c.stateName(),
Served: c.active(),
IsAnchor: anchorHost != "" && c.HostName == anchorHost,
}
h.Basis = uiGivenBasis(c.HostName, h.IsAnchor)
dcnHeld := false
for _, fam := range uiGivenFamilies {
f := fam // the description; Held and Ops are this pairing's
f.Ops = byFamily[fam.Name]
f.Held = capsAllow(c.HostName, fam.Probe) == ""
// READ FROM THE DENY LIST, not inferred from Held. capsAllow
// already refuses when this is switched off, so Held is false
// either way — and the page has to tell the two APART: "the host
// was never granted this" and "you switched it off" are different
// facts, and only one of them has a way back.
f.SwitchedOff = off[c.HostName+"/"+fam.Name]
if f.Held {
h.HeldCount++
}
// Read off the same loop rather than asking capsAllow a second time
// with a hardcoded op: the folder list and the "Read your files" card
// have to be answering the same question, or the page contradicts
// itself two headings apart.
if fam.Name == "dcn" {
dcnHeld = f.Held
}
h.Families = append(h.Families, f)
}
// The box-wide directives (writes.go): not a caps family — no grant
// names them and only the anchor host directs them — but a thing this
// host may do to the machine, and since 0.33.0 a thing the person can
// switch off here. Held is asked of the directives' own gate
// (anchorOnly + the switch), never of capsAllow, so the card answers
// the question the directives actually face.
d := uiDirectivesCard
d.Held = anchorOnly(c.HostName, localFamilyDirectives) == "" && !localDenied(c.HostName, localFamilyDirectives)
d.SwitchedOff = off[c.HostName+"/"+localFamilyDirectives]
if d.Held {
h.HeldCount++
}
h.Families = append(h.Families, d)
h.Folders, h.FolderNote = uiFoldersFor(c, dcnHeld)
g.Hosts = append(g.Hosts, h)
}
return g
}
// ── the routes ──────────────────────────────────────────────────────────────
func (u *uiServer) givenPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
u.renderGiven(w, "given.html")
}
func (u *uiServer) givenFragment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
u.renderGiven(w, "given")
}
// renderGiven sets the SAME four headers renderWith does (ui.go:343-348) and
// not one fewer. They are repeated rather than shared because renderWith is
// typed to uiStatus and widening it belongs to whoever owns that file; the
// duplication is made safe by TestTheGivenPageCarriesTheSameHeadersAsTheStatusPage,
// which compares the two responses header by header, so a change there that
// this file misses is a red test rather than a page quietly served without a CSP.
func (u *uiServer) renderGiven(w http.ResponseWriter, name string) {
u.renderGivenFlash(w, name, "", "")
}
// renderGivenFlash is renderGiven with the outcome of a switch carried into the
// fragment that replaces the page.
func (u *uiServer) renderGivenFlash(w http.ResponseWriter, name, flash, class string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; form-action 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
v := collectGiven()
v.Flash, v.FlashClass = flash, class
if err := u.tpl.ExecuteTemplate(w, name, v); err != nil {
uilogf("render %s: %v\n", name, err)
}
}
// givenSwitch turns one capability off for one host on this machine, or back
// on. POST, so the Origin check applies.
//
// IT CANNOT GRANT. The only thing this writes is a deny list; "on" removes an
// entry from it and the host's signed grant remains the ceiling it always was
// (localdeny.go, and TestTheLocalSwitchCanOnlyEverRemoveReach drives the real
// gate over the whole op vocabulary to hold that).
func (u *uiServer) givenSwitch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
host := strings.TrimSpace(r.FormValue("host"))
family := strings.TrimSpace(r.FormValue("family"))
off := r.FormValue("off") == "1"
if err := localDenySet(host, family, off); err != nil {
u.renderGivenFlash(w, "given", "Nothing was changed: "+err.Error(), "bad")
return
}
// The words say what the machine will now DO, not what was written to a
// file. "Switched off" is a state; "will refuse" is the consequence, and
// the consequence is what the person came here for.
if off {
u.renderGivenFlash(w, "given", host+" can no longer "+uiFamilyVerb(family)+
" on this machine. Its grant is untouched — this machine simply refuses, "+
"and tells it why.", "ok")
return
}
u.renderGivenFlash(w, "given", host+" may "+uiFamilyVerb(family)+" again, up to whatever "+
"its signed grant already allowed. Turning this back on clears a refusal; it "+
"grants nothing.", "ok")
}
// uiDirectivesCard describes the three host-directed, box-wide actions
// (update.go, contain.go, mirror.go). Kept apart from uiGivenFamilies, which
// is asserted equal to allCaps: this is not a capability a grant can carry.
var uiDirectivesCard = uiCapFamily{
Name: localFamilyDirectives,
Line: "Change this machine as a whole: swap the bastion binary, stop a program, place a database mirror.",
Detail: "Only the host this machine is anchored to may direct these, and since 0.33.0 each " +
"one also needs your approval — from your approval device or this console — bound to " +
"exactly what it would do (the version, the processes, the mirror slot). Since 0.44.0 an " +
"update to a newer version is the exception: our signature on the release is the " +
"approval, and a move to an older version still needs yours. Switching this off here " +
"refuses all three, approved or not.",
Ops: []string{directiveSelfUpdate, directiveContain, directiveMirror},
}
// uiFamilyVerb is the family said as an action, for the sentence above.
func uiFamilyVerb(family string) string {
for _, f := range append(uiGivenFamilies, uiDirectivesCard) {
if f.Name == family {
return strings.TrimSuffix(strings.ToLower(f.Line), ".")
}
}
return "use '" + family + "'"
}