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 // ` (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. // An unreadable list switches every family off (localDenied), and // DenyNote says why and how to reset it. f.SwitchedOff = off[c.HostName+"/"+fam.Name] || denyNote != "" 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] || denyNote != "" 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" // Switching OFF only takes reach away and needs nothing. Turning it back // ON gives the Host its reach again (up to its grant), which the // terminal's `switch ... on` asks the administrator for; on this page it // takes the owner's code, because anything driving the browser can press // the button. if !off { if why := ownerCodeRefusal(r.FormValue("code"), "local_switch_on|"+host+"|"+family); why != "" { u.renderGivenFlash(w, "given", "Not turned back on: "+why, "warn") return } } 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 + "'" }