package main // ui.go — the bastion's local UI: what this machine is doing, on this machine. // // WHY IT EXISTS. Flashing console windows, a terminal that vanished after a // self-update, and nothing on the machine to look at: three separate defects // were the same defect. The bastion is a program with no face, and what it // needs is a UI local to the machine. It runs, holds credentials, serves a remote host, // reads folders and can open a root shell — and on the machine it lives on it is // a name in Task Manager. `status` had to be TYPED to learn anything, and after // a self-update there is no console to type it in (relaunch_windows.go). // // The full capability catalogue is larger than this file, and what each part // of it needs is set out in the local UI's design. This file is phase 1 of // that: SEE IT. // // THE LINE THIS MUST NOT CROSS. The UI is a view of, and a gate on, THIS // MACHINE. It never originates a request to the host. The bastion has no MCP // ingress — removed deliberately, and run.go still says why: // "nothing on this box drives the host." A local UI is the most natural place in // the world to re-introduce that by accident, one convenience button at a time. // // STACK. Go + htmx, one binary. deploy/publish.sh builds all // six targets in ONE alpine container with CGO_ENABLED=0, which is what makes // the bastion a static binary with no runtime dependencies. htmx costs nothing // against that: the script is embedded, the server renders fragments, and no npm // or bundler enters a security binary's release. It is vendored in uiassets/ and // never fetched from a CDN — a privacy product must not pull a script off the // internet to show you your own machine, and on a locked-down box it would fail. import ( "context" "embed" "encoding/json" "fmt" "html/template" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" ) // uilogf is the UI's own log line. NOT run.go's logf — that one is a closure // inside runPairing and prefixes every line with the pairing's host name // (run.go:298), which is exactly what a machine-wide server must not claim: the // UI belongs to the box, not to any host it serves. func uilogf(format string, a ...any) { fmt.Printf("[ui] "+format, a...) } //go:embed uiassets/htmx.min.js uiassets/app.css uiassets/given.css uiassets/setup.css uiassets/diskmap.css uiassets/agents.css uiassets/policy.css uiassets/context.css uiassets/home.css var uiStaticFS embed.FS //go:embed uiassets/page.html uiassets/status.html uiassets/given.html uiassets/setup.html uiassets/diskmap.html uiassets/agents.html uiassets/policy.html uiassets/context.html uiassets/approvals.html uiassets/home.html var uiTemplateFS embed.FS // uiPortHigh is the port the UI prefers, and it is one BELOW the pairing range // on purpose. nextListenAddr (config.go:563) allocates a control port per // pairing counting UP from 7878, so 7877 is a port it can never reach, however // many hosts this machine ends up serving. uiPortLow leaves room to move if // something else on the box already holds it. const ( uiPortHigh = 7877 uiPortLow = 7862 ) // ── the data the page shows ───────────────────────────────────────────────── type uiPairing struct { Host string State string ListenAddr string Listening bool TunnelIP string FirstSeen string IsAnchor bool // WaitingFor: pending, waiting for this Host (the one in charge of the // machine) to admit it; nothing runs for it until then. WaitingFor string } // uiApproval is one thing a host has asked this machine to allow, waiting for // the person sitting at it. type uiApproval struct { ID string // full id; the form posts this, never the short form Short string // what a human reads, matching the CLI's [xxxxxxxx] Host string At string Lines []string // describeRequest, in the box's own words // Unreadable: this bastion does not know what it is being asked for, so it // cannot put it in words. See uiUnknownRequest. Unreadable bool // PurposeSHA256 is the sha256 of the purpose these lines describe. The // tray's lane (approvalpipe.go) carries it back with the code, so what is // approved is the request that was read. PurposeSHA256 string } // uiRefusal is a host-directed action this box would not carry out // (directive_refusal.go). It already reaches the HOST on every enforcing // report; the person standing at the machine got a fmt.Printf into a console // that, after a self-update, does not exist. type uiRefusal struct { What string Why string At string } // uiEvent is one thing that happened TO this machine (events.go), in local // time. Recent drives the emphasis and nothing else — an event is never // removed, it stops shouting. Without that, a containment from three months ago // would still be a red block at the top of the page, which is the stale-alarm // failure directive_refusal.go:27-29 already names. type uiEvent struct { At string Text string Recent bool } // uiEventRecentFor is how long an event is emphasised. A week, because there is // no dismiss button and none is wanted — the emphasis has to time out by // itself, and seven days is the coarsest span over which "whoever uses this // machine has had a chance to look at it" is true for someone who is not at it // daily. It is a styling threshold, never a filter. const uiEventRecentFor = 7 * 24 * time.Hour // uiEventTime is date-and-time, not the state block's clock-only format: these // are things that happened, and "14:32" with no day is unreadable on the one // line of the page where the day is the point. const uiEventTime = "2 Jan 2006, 15:04" // Admin is `privatae-bastion ` as this machine's administrator types it, // for what a page tells a limited session to ask of them. func (uiStatus) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) } type uiStatus struct { // Limited: drawn for the owner's lane (consolelane.go), which may not // answer the approval device's requests. Limited bool Version string State string StateClass string StateNote string Mode string ModeNote string AnchorHost string AnchorOwner string AnchorRequired bool Owner string NoConsoleNote bool Pairings []uiPairing Approvals []uiApproval // DeviceApprovals: asks your hosts filed on THIS machine as your approval // device (uidevice.go). IsDevice says whether it is one at all. DeviceApprovals []uiDeviceApproval IsDevice bool // MachineAsks: requests asked of this machine itself, answered with the // owner's code (stepup_machine.go): today, stops an agent asked for. MachineAsks []uiMachineAsk // ApprovalsView: this render is for the approvals popup, so the step-up // cards post back into it rather than into the front page. ApprovalsView bool // Refusal, Events and UpdateAvailable are the three things this box already // knew and told nobody who was here. See collectUIStatus. Refusal *uiRefusal Events []uiEvent EventsRecent bool UpdateAvailable string CheckedAt string // Flash is the outcome of the last approve/deny, carried into the fragment // that replaces the page. Empty on an ordinary poll. Flash string FlashClass string // TrayView: drawn by the person's tray beside a system service // (uitray.go), which reads the machine through the approvals pipe. // TrayError says why that read failed. TrayView bool TrayError string } // collectUIStatus reads the same sources cmdStatus does, so the page and the // command can never disagree. It probes liveness per pairing exactly as status // does — a plain TCP connect, identical on every platform. func collectUIStatus() uiStatus { s := uiStatus{ Version: clientVersion, Mode: boxMode(), CheckedAt: time.Now().Format("15:04:05"), } if s.Mode == modeNetworked { s.ModeNote = "several hosts may be served here; the anchor approves each one" } else { s.ModeNote = "one host, this user" } if a := currentAnchor(); a != nil { s.AnchorHost, s.AnchorOwner = a.AnchorHost, a.Owner } else if s.Mode == modeNetworked { // The gate in anchor.go: a networked box with no anchor serves nothing. // The user has to be told that in the place they are looking, not only // in the refusal text of an op they cannot see. s.AnchorRequired = true } if o := currentOwnerClaim(); o != nil { s.Owner = o.Owner } // The device lane is this machine's, not a pairing's: read it before the // pairings, so a machine whose pairing list cannot be read still shows the // asks waiting on it. s.IsDevice = isApprovalDevice() s.MachineAsks = machineAsks() s.DeviceApprovals = pendingDeviceApprovals() // The three things this machine already knew and told nobody standing at it. // A containment printed to stdout and kept nothing (the Printf at // contain.go:106, which is all there was); a // refused directive went to the host on the enforcing report and no further // (directive_refusal.go:60); and the box never asked whether it was current, // because fetchManifest had exactly one caller and that caller runs only // when the host directs an update (update.go). s.UpdateAvailable = updateAvailable() if r := directiveRefusalReport(); r != nil { what, _ := r["what"].(string) why, _ := r["why"].(string) at, _ := r["at"].(string) s.Refusal = &uiRefusal{What: what, Why: why, At: uiWhen(at)} } for _, e := range localEventsNewestFirst() { recent := time.Since(e.At) < uiEventRecentFor s.EventsRecent = s.EventsRecent || recent s.Events = append(s.Events, uiEvent{ At: e.At.Local().Format(uiEventTime), Text: e.Text, Recent: recent, }) } cfgs, err := loadPairings() if err != nil { s.State, s.StateClass = "CANNOT READ THIS MACHINE'S STATE", "bad" s.StateNote = err.Error() return s } running, servable, waiting := 0, 0, 0 who := admitter() for _, c := range cfgs { p := uiPairing{ Host: c.HostName, // As the setup page and `status` read it: a pairing whose file // cannot be served is "invalid" whatever its stored state says. State: c.stateName(), ListenAddr: c.ListenAddr, Listening: bastionListening(c.ListenAddr), TunnelIP: c.BastionTunnelIP, FirstSeen: c.FirstSeen, IsAnchor: s.AnchorHost != "" && c.HostName == s.AnchorHost, } if c.invalid == "" && c.State == pairingPending && who != "" { p.WaitingFor = who waiting++ } if c.active() { servable++ if p.Listening { running++ } } s.Pairings = append(s.Pairings, p) s.Approvals = append(s.Approvals, pendingApprovals(c)...) } wait := "" if waiting > 0 { wait = fmt.Sprintf(" %d more wait(s) for %s to admit them: %s asks you to approve each.", waiting, who, who) } switch { case len(cfgs) == 0: // No Host is a steady state, not a fault (run.go): the bastion watches // this machine on its own. s.State, s.StateClass = "ON ITS OWN", "ok" s.StateNote = "No Host is connected. The bastion watches the AI agents on this machine on its own." case servable == 0 && waiting > 0: s.State, s.StateClass = "WAITING", "warn" s.StateNote = strings.TrimSpace(fmt.Sprintf("No Host is served yet.%s", wait)) case servable > 0 && running == servable: s.State, s.StateClass = "RUNNING", "ok" s.StateNote = fmt.Sprintf("Serving %d host(s) from this machine.%s", servable, wait) s.NoConsoleNote = runtime.GOOS == "windows" && !systemService.installed case running > 0: s.State, s.StateClass = "PARTLY RUNNING", "warn" s.StateNote = fmt.Sprintf("%d of %d hosts are being served; the rest are not answering.%s", running, servable, wait) default: s.State, s.StateClass = "NOT RUNNING", "bad" s.StateNote = "Nothing is serving these pairings. Start it with `" + runCommandHint() + "`." + wait } return s } // uiWhen renders a stored RFC3339 stamp in the local time of the person reading // it. An unparseable stamp is shown AS IT IS rather than dropped: a timestamp // this code cannot read is still evidence, and blanking it would make a refusal // look like it had no time at all. func uiWhen(rfc3339 string) string { if t, err := time.Parse(time.RFC3339, rfc3339); err == nil { return t.Local().Format(uiEventTime) } return rfc3339 } // pendingApprovals is what one host is waiting on this machine to allow. // // Per PAIRING, because that is where the state lives: each host has its own DCN // dir with its own enrolment and its own request file (stepup.go). The UI is // machine-wide, so it asks every pairing and shows one list — which is the thing // the CLI cannot do, since `approvals` takes a --host and answers for one. func pendingApprovals(c *Config) []uiApproval { // A PENDING OR REVOKED PAIRING IS NOT ASKED, and not because it has nothing // to say: dcnDir() is MkdirAll-on-read (config.go:115), so asking created // the directory. runPairing guards every such call behind cfg.active() // because "the order of the code below can never leak a side effect for a // host the anchor has not approved" (run.go:311-318) — and rendering a page // was doing exactly that, for every pairing, every three seconds. A pairing // with no lanes can have no requests either way. if !c.active() { return nil } dir := c.dcnDir() if dir == "" || !stepupEnrolled(dir) { return nil } stepupMu.Lock() m := requestsLoad(dir) stepupMu.Unlock() var out []uiApproval for _, id := range sortedPending(m) { short := id if len(short) > 8 { short = short[:8] } desc := describeRequest(m[id]) var lines []string for _, l := range strings.Split(desc, "\n") { if strings.TrimSpace(l) != "" { lines = append(lines, l) } } out = append(out, uiApproval{ ID: id, Short: short, Host: c.HostName, At: m[id].At, Lines: lines, Unreadable: desc == uiUnknownRequest, PurposeSHA256: purposeDigest(m[id].Purpose), }) } return out } // uiUnknownRequest is what describeRequest returns when details["kind"] is not // one of the five it knows how to put into words (stepup.go). // // IT MATTERS MORE IN A UI THAN AT A PROMPT. The CLI prints "(unknown request)" // and then makes you type a six-digit code, which is at least a pause. A page // with a green Approve button beside text that says nothing is an invitation to // approve something unreadable, and the request came from the far side of a // tunnel. So the card changes shape: Deny becomes the primary action, Approve // is demoted, and the reason is said out loud. // // This is not a theoretical case. It is what a machine sees when its host is // NEWER than it is and has learned to ask for something this version has never // heard of — which is the ordinary state of a fleet mid-upgrade. const uiUnknownRequest = "(unknown request)" // ── the server ────────────────────────────────────────────────────────────── type uiServer struct { port string // "7877" — the guard compares Host and Origin against it secret string tickets *uiTokenStore sessions *uiTokenStore tpl *template.Template // addr is where the daemon's console listens, for links minted on the // owner's lane; limitedTickets and limitedSessions are that lane's own // (consolelane.go), kept apart so a lane session is never an admin one. addr string limitedTickets *uiTokenStore limitedSessions *uiTokenStore // contextUI marks the USER-RUN surface (contextui.go). It selects a // different ROUTE SET — not a different permission check, and that // distinction is the whole design. This process runs as the person who // typed `privatae-bastion context`, so the files it can read are the ones // the kernel already lets them read; nothing here adjudicates that, and // nothing here could widen it. // // What the flag DOES do is refuse to mount the machine-admin routes, so a // user-run process cannot serve the pairing list, the anchor, the marks or // the policy even to the person who started it. Held mechanically by // TestTheContextSurfaceServesNoMachineAdminRoute. contextUI bool // contextHome is the one directory this surface covers, resolved once at // start-up from the invoking user's own environment. contextHome string // contextMu guards contextAsks, the in-memory list of what this person asked // for from this window (uicontext.go). contextMu sync.Mutex contextAsks []uiContextAsk // trayPipe marks the TRAY's own page beside a system service (uitray.go): // another route set, run by the person's tray, that reads and answers the // service through the approvals pipe. pipe is that call; a variable so the // suite drives the page off Windows. trayPipe bool pipe func(pipeRequest) (pipeResponse, error) } func newUIServer(port, secret string) (*uiServer, error) { tpl, err := template.ParseFS(uiTemplateFS, "uiassets/page.html", "uiassets/status.html", "uiassets/given.html", "uiassets/setup.html", "uiassets/diskmap.html", "uiassets/agents.html", "uiassets/policy.html", "uiassets/context.html", "uiassets/approvals.html", "uiassets/home.html") if err != nil { return nil, err } return &uiServer{ port: port, secret: secret, tickets: newUITokenStore(uiTicketTTL), sessions: newUITokenStore(uiSessionTTL), tpl: tpl, limitedTickets: newUITokenStore(uiTicketTTL), limitedSessions: newUITokenStore(uiSessionTTL), }, nil } // verb is the command that opens this surface. ONE answer, used by every // refusal on both surfaces: the first cut computed it inline in root() and the // three refusals reachable through guarded() kept saying `ui` on a page only // `files` can open. func (u *uiServer) verb() string { if u.contextUI { return "context" } if u.trayPipe { return "tray" } return "ui" } // status is the machine as this surface shows it: read directly by the admin // console, through the approvals pipe by the tray's page. func (u *uiServer) status() uiStatus { if u.trayPipe { return trayPipeStatus(u.pipe) } return collectUIStatus() } func (u *uiServer) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/assets/", u.serveAsset) mux.HandleFunc("/auth/ticket", u.mintTicket) mux.HandleFunc("/signout", u.signOut) // THE CONTEXT SURFACE MOUNTS NOTHING ELSE. An early return rather than a // condition wrapped around each block below: a route added to this function // next year lands on the admin console by default, which is the safe // direction, and the alternative — remembering to exclude it — is the kind // of guard that holds until somebody is in a hurry. if u.contextUI { u.contextRoutes(mux) return mux } // THE TRAY'S PAGE MOUNTS ONLY ITS OWN, for the same reason. if u.trayPipe { u.trayRoutes(mux) return mux } mux.HandleFunc("/fragments/status", u.guarded(u.statusFragment)) // What this machine has given away (uigiven.go): the caps ceiling per host // and the folders shared from here. Read-only, and guarded like every other // route — it is the most security-relevant page in the binary. mux.HandleFunc("/given", u.guarded(u.givenPage)) // THE ONE MUTATING ROUTE ON THIS PAGE, and it can only ever remove reach // (localdeny.go). The console can switch a granted capability off: that is // local control the person at the machine needs. mux.HandleFunc("/given/switch", u.guarded(u.givenSwitch)) mux.HandleFunc("/fragments/given", u.guarded(u.givenFragment)) mux.HandleFunc("/approve", u.guarded(u.approve)) mux.HandleFunc("/deny", u.guarded(u.deny)) // The approvals popup (uidevice.go): everything waiting on the person, and // the approval device's Approve / Deny, reachable from the taskbar. u.deviceRoutes(mux) // Phase 3 (uisetup.go): pairing, the step-up QR, the folder picker, and the // two root-gated choices the page presents but does not perform. u.setupRoutes(mux) // The disk map (uidiskmap.go): a treemap of this machine's own storage, // read from names and sizes only. A FILE LISTING IS SENSITIVE — the names // alone say what a person works on — so these are guarded like everything // else, and are in both leak tests in ui_test.go. u.diskMapRoutes(mux) // The automated programs this machine can see, and which of them the person // here has said are theirs (uisanction.go). Deliberately no control that // stops anything — see that file's header. u.agentRoutes(mux) // The dry-run policy (uipolicy.go): what a rule WOULD stop, and what it // would have stopped over the past week. Two routes, both reads — there is // no mutating route in that unit at all, which is the point of it. u.policyRoutes(mux) // The agents and what each is doing (uihome.go): the home page, one page // per agent, and the machine's state page that used to be home. u.homeRoutes(mux) mux.HandleFunc("/", u.root) return mux } // guarded wraps a handler that requires a signed-in session. func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if why := uiRequestAllowed(r, u.port, u.verb()); why != "" { http.Error(w, why, http.StatusForbidden) return } if !u.signedIn(r) { http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized) return } if u.limitedSession(r) { w = uiLimitedWriter{w} } h(w, r) } } // notSignedIn is what a page says to a browser with no session: the command // that opens it on this machine. The console of a bastion installed for the // whole machine opens from its owner's own account through the lane // (consolelane.go), and from the administrator's; telling either one only // "privatae-bastion ui" sent the person back to the command that, from the // wrong account, could not open it. func notSignedIn(verb, goos string, machines bool) string { cmd := "privatae-bastion " + verb switch { case !machines: return "not signed in — open this page with `" + cmd + "`" case goos == "windows": return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd + "` from the account that owns this machine, or with `" + cmd + "` in an Administrator terminal" } return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd + "` from the account that owns this machine, or with `sudo -H " + cmd + "`" } // consoleIsTheMachines: this console is the bastion installed for the whole // machine (the Windows service, or root's on Linux and macOS). func consoleIsTheMachines() bool { return runningAsService || (runtime.GOOS != "windows" && os.Geteuid() == 0) } // root serves the page, and is also where a ticket is redeemed. func (u *uiServer) root(w http.ResponseWriter, r *http.Request) { if why := uiRequestAllowed(r, u.port, u.verb()); why != "" { http.Error(w, why, http.StatusForbidden) return } if r.URL.Path != "/" { http.NotFound(w, r) return } // ?k=: redeem it, set the session, and REDIRECT to a clean URL so // the ticket is not left in the address bar, the history or a screenshot. // It is one-use and dies in a minute anyway; this is the second lock. if k := r.URL.Query().Get("k"); k != "" { // A ticket from the owner's lane opens a limited session, and only // that (consolelane.go). store := u.sessions if !u.tickets.redeem(k) { if u.limitedTickets == nil || !u.limitedTickets.redeem(k) { http.Error(w, "that link has already been used or has expired — "+ "run `privatae-bastion "+u.verb()+"` again", http.StatusForbidden) return } store = u.limitedSessions } tok, err := store.mint() if err != nil { http.Error(w, "could not start a session", http.StatusInternalServerError) return } uiSetSessionCookie(w, tok) // A ticket may name where it lands (?to=), but only a page on the short // list in uidevice.go: the link goes through a browser, and an open // redirect on it would be a phishing tool pointed at this machine. http.Redirect(w, r, uiLanding(r.URL.Query().Get("to")), http.StatusSeeOther) return } if !u.signedIn(r) { http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized) return } if u.contextUI { // The context surface has no status page to land on, and `p` is how it // walks into a folder — so root IS the map. u.contextPage(w, r) return } if u.trayPipe { // The tray's page is the approvals window and nothing else. u.approvalsPage(w, r) return } u.homePage(w, r) } func (u *uiServer) statusFragment(w http.ResponseWriter, r *http.Request) { u.render(w, "status") } func (u *uiServer) render(w http.ResponseWriter, name string) { u.renderWith(w, name, "", "") } // renderWith re-reads the machine's state and renders it, carrying an outcome // line. The state is ALWAYS re-read rather than patched: after an approve the // request is gone, and rendering a remembered list would leave the card the // user just answered sitting on screen. func (u *uiServer) renderWith(w http.ResponseWriter, name, flash, class string) { u.renderStatus(w, name, flash, class, false) } // renderStatus is the one render path: a fresh read of the machine, the outcome // line, and whether the popup (not the front page) is what is being drawn. func (u *uiServer) renderStatus(w http.ResponseWriter, name, flash, class string, approvalsView bool) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") // This page renders only local state, but it costs nothing to say that // nothing may be loaded into it from anywhere else. w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; form-action 'self'") w.Header().Set("X-Content-Type-Options", "nosniff") s := u.status() s.Flash, s.FlashClass = flash, class s.ApprovalsView = approvalsView s.Limited = limitedWriter(w) if err := u.tpl.ExecuteTemplate(w, name, s); err != nil { uilogf("render %s: %v\n", name, err) } } // renderAnswer draws the outcome of a step-up approve or deny into whichever // page the card was on: the popup posts view=approvals, the front page does not. func (u *uiServer) renderAnswer(w http.ResponseWriter, r *http.Request, flash, class string) { if r.FormValue("view") == "approvals" { u.renderApprovals(w, "approvals", flash, class) return } u.renderWith(w, "status", flash, class) } // approvalTarget resolves the posted host+id to the pairing's DCN dir. // // The host is looked UP among this machine's pairings rather than trusted as a // path component: the form is local and session-authed, but a handler that // joins a posted string onto a directory is the shape of bug that turns a local // UI into a file-system reach, and there is no reason to write it that way when // the pairing list is right here. func approvalTarget(host string) (*Config, error) { cfgs, err := loadPairings() if err != nil { return nil, err } for _, c := range cfgs { if c.HostName == host { // The same active() guard the reading paths take, and here it is a // correctness rule as well as a side-effect one: approving a request // for a pairing the anchor has not approved would answer on behalf // of a host that holds no lanes to act on the answer. Both callers // below reach cfg.dcnDir(), which is MkdirAll-on-read. if !c.active() { return nil, fmt.Errorf("this machine does not serve %q yet — that pairing is %s", host, c.stateName()) } return c, nil } } return nil, fmt.Errorf("this machine does not serve %q", host) } // approve: the whole reason the UI exists. The host asked for something, the // request has been sitting in a file, and until now the only way to answer was // to know to type `privatae-bastion approvals` at a terminal that, on Windows, // may not exist. // // NO ATTEMPT THROTTLE, deliberately. Step-up defends against a compromised // HOST — the host never handles the code — and a host cannot reach this form at // all: the UI is loopback-only and the bastion has no MCP ingress. The only // caller who can reach it is a process running as this user, and that process // can read the TOTP seed straight out of stepup.json (stepupFile.SecretB32) in // the same directory. Guessing codes gains an attacker nothing over reading the // file, so a lockout would cost a real user a locked account and cost an // attacker nothing. func (u *uiServer) approve(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } host, id, code := r.FormValue("host"), r.FormValue("id"), strings.TrimSpace(r.FormValue("code")) cfg, err := approvalTarget(host) if err != nil { u.renderAnswer(w, r, err.Error(), "bad") return } if code == "" { u.renderAnswer(w, r, "Enter the code from the authenticator enrolled on this machine.", "warn") return } approveOne := func() (string, error) { return stepupApproveRequest(cfg.dcnDir(), id, code) } if u.limitedSession(r) { // Through the owner's lane the seed is out of the caller's reach, so // the reasoning above no longer holds: codes are throttled here as the // owner's are (consolelane.go). dir := cfg.dcnDir() approveOne = func() (string, error) { return codeSpend(dir, time.Now(), hostCodeLockedHint, func() (string, error) { return stepupApproveRequest(dir, id, code) }) } } if _, err := approveOne(); err != nil { u.renderAnswer(w, r, "Not approved: "+err.Error(), "bad") return } u.renderAnswer(w, r, "Approved. "+host+" picks this up on its next check-in, within a minute.", "ok") } func (u *uiServer) deny(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } host, id := r.FormValue("host"), r.FormValue("id") cfg, err := approvalTarget(host) if err != nil { u.renderAnswer(w, r, err.Error(), "bad") return } // Denying needs no code: refusing is always the safe direction, and asking // for a second factor to say NO would train people to approve instead. if err := stepupDenyRequest(cfg.dcnDir(), id); err != nil { u.renderAnswer(w, r, "Not denied: "+err.Error(), "bad") return } u.renderAnswer(w, r, "Denied. "+host+" is told on its next check-in.", "ok") } // mintTicket is the ONE route authenticated by the long-lived secret rather than // a cookie, and it is the CLI that calls it. It deliberately does NOT take the // Origin check the cookie routes take: a bearer header cannot be attached by a // page cross-origin without a preflight this mux never answers, so there is no // CSRF to defend against here — while a Go client legitimately sends no Origin // at all. The Host check still applies, because rebinding does not care what // authenticates. func (u *uiServer) mintTicket(w http.ResponseWriter, r *http.Request) { if !uiLoopbackHost(r.Host, u.port) { http.Error(w, "not reachable this way", http.StatusForbidden) return } if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } offered := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if !uiSecretMatches(offered, u.secret) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } tok, err := u.tickets.mint() if err != nil { http.Error(w, "could not mint", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"ticket": tok}) } // signOut is POST-only, which it was not at first. As a GET it // took only the Host check — uiRequestAllowed exempts reads from the Origin // check, correctly, because a read is safe — so any page could have signed the // user out with an . Harmless in effect and unreachable today (nothing // links here), but a logout that any site can trigger is a papercut with a // well-known name, and the fix is one line. As a POST it takes the Origin check // like every other state change. func (u *uiServer) signOut(w http.ResponseWriter, r *http.Request) { // HOST CHECK FIRST, method second. Putting the method check on top read // fine and answered a rebound host with 405 instead of 403 — refused either // way, but it tells a caller the route exists and what it takes, and the // rebinding wall is supposed to be the OUTERMOST thing on every route. // Caught by TestEveryRouteRefusesARebindingHost, which walks all of them. if why := uiRequestAllowed(r, u.port, u.verb()); why != "" { http.Error(w, why, http.StatusForbidden) return } if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } u.sessions.drop(uiSessionFrom(r)) if u.limitedSessions != nil { u.limitedSessions.drop(uiSessionFrom(r)) } uiClearSessionCookie(w) http.Redirect(w, r, "/", http.StatusSeeOther) } func (u *uiServer) serveAsset(w http.ResponseWriter, r *http.Request) { if !uiLoopbackHost(r.Host, u.port) { http.Error(w, "not reachable this way", http.StatusForbidden) return } // Assets are not secret and are needed before a session exists (the error // pages are plain text, but the page itself loads them on first paint). name := strings.TrimPrefix(r.URL.Path, "/assets/") switch name { case "htmx.min.js", "app.css", "given.css", "setup.css", "diskmap.css", "agents.css", "policy.css", "context.css", "home.css": default: http.NotFound(w, r) return } b, err := uiStaticFS.ReadFile("uiassets/" + name) if err != nil { http.NotFound(w, r) return } if strings.HasSuffix(name, ".js") { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } else { w.Header().Set("Content-Type", "text/css; charset=utf-8") } w.Header().Set("X-Content-Type-Options", "nosniff") // Embedded in the binary, so it changes only when the binary does. w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write(b) } // ── lifecycle ─────────────────────────────────────────────────────────────── // uiAddrPath holds the address the UI actually bound, so `privatae-bastion ui` // does not have to guess it after a fallback. func uiAddrPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "ui.addr"), nil } // startUIServer binds the UI and serves until ctx is done. ONE per machine, // started from cmdRun — not per pairing, because the UI shows the whole box. // // LOOPBACK IS THE POINT, not an oversight. This listener exposes the pairing // list, the anchor, the machine owner and (phase 2) the approval prompts. It is // the user's own machine talking to the user sitting at it. Binding anything // wider would publish a person's control surface to their LAN, which is the // exact inverse of what this product is. Nothing off-box ever reaches it: the // host reaches this machine only through the lanes the bastion dials OUT. func startUIServer(ctx context.Context) { // Claimed BEFORE the port work below, which has two early returns. The // record of what a host did to this machine must not depend on a free TCP // port — events.go's header has the rest — and the version check is the only // thing in the binary that can tell a user their machine is behind. openLocalEventLog() go runUpdateAvailabilityPoll(ctx) // The dry-run policy census (policy.go). Here beside the update poll rather // than inside the page, because a measurement of what a rule WOULD stop over // a week has to accumulate while nobody has a browser open — and because a // render that wrote to disk is the defect ui_approvals_flow_test.go:9-15 exists // for. It writes only when the set of candidates changes or its own clock // goes stale, so a machine with nothing to report costs nothing. // // ABOVE the port work below for the same reason the event log is: that code // has two early returns, and what this rule would have taken away from // somebody must not go unrecorded because a TCP port was busy. go runPolicyCensus(ctx) // The context socket (contextsock.go): how a LOCAL USER who is not this // daemon's owner asks for a folder to become context. Started here, beside // the other background lanes and above the port work, for the same reason // they are — that code has early returns, and a person's ability to ask // must not depend on a free TCP port. startContextSock(ctx.Done()) // Asks from other accounts to stop an agent (agentask.go). startAskSock(ctx.Done()) secret, err := loadOrCreateUISecret() if err != nil { uilogf("not started (%v)\n", err) return } var ln net.Listener var port string for p := uiPortHigh; p >= uiPortLow; p-- { l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { ln, port = l, fmt.Sprint(p) break } } if ln == nil { uilogf("no free port between %d and %d — the local UI is not available\n", uiPortLow, uiPortHigh) return } srv, err := newUIServer(port, secret) if err != nil { uilogf("not started (%v)\n", err) _ = ln.Close() return } addr := ln.Addr().String() srv.addr = addr if p, err := uiAddrPath(); err == nil { _ = os.WriteFile(p, []byte(addr), 0o600) } consoleLaneServer.Store(srv) h := &http.Server{ Handler: srv.handler(), ReadHeaderTimeout: 5 * time.Second, } go func() { <-ctx.Done() consoleLaneServer.CompareAndSwap(srv, nil) sh, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = h.Shutdown(sh) }() go func() { uilogf("listening on http://%s — open it with `privatae-bastion ui`\n", addr) if err := h.Serve(ln); err != nil && err != http.ErrServerClosed { uilogf("%v\n", err) } }() } // ── the `ui` verb ─────────────────────────────────────────────────────────── // localUIEntryURL mints a one-use ticket against the RUNNING daemon and returns // the URL that redeems it, plus the address it is on. // // The tray (tray_windows.go) goes through this same path even though it lives // inside the daemon and could mint from the server object directly. One code // path, exercised identically by the CLI and by the menu item, is worth more // than saving a sub-millisecond round trip on loopback — and it means a tray // that can open the UI proves the CLI can too. func localUIEntryURL() (entry, addr string, err error) { return localUIEntryURLTo("") } // localUIEntryURLTo is localUIEntryURL landing on `to` after sign-in (one of // uiLandingPaths; anything else lands on the front page). func localUIEntryURLTo(to string) (entry, addr string, err error) { addr = "127.0.0.1:" + fmt.Sprint(uiPortHigh) if p, err := uiAddrPath(); err == nil { if b, err := os.ReadFile(p); err == nil && len(b) > 0 { addr = strings.TrimSpace(string(b)) } } if !bastionListening(addr) { return "", addr, fmt.Errorf("the bastion is not running on this machine, so "+ "there is nothing to show.\n Start it with `%s`, then run this again", runCommandHint()) } secret, err := loadOrCreateUISecret() if err != nil { return "", addr, err } req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/auth/ticket", nil) if err != nil { return "", addr, err } req.Header.Set("Authorization", "Bearer "+secret) resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) if err != nil { return "", addr, fmt.Errorf("could not reach the local UI at %s: %w", addr, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", addr, uiKeyRefused(addr, resp.Status, runtime.GOOS, systemService.installed, machineAuthorityHeld()) } var out struct { Ticket string `json:"ticket"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" { return "", addr, fmt.Errorf("the local UI returned no ticket") } entry = "http://" + addr + "/?k=" + out.Ticket if to != "" { entry += "&to=" + url.QueryEscape(to) } return entry, addr, nil } // uiKeyRefused is the console refusing this account's own key: the console // on that port belongs to another bastion than this account's. func uiKeyRefused(addr, status, goos string, service, admin bool) error { switch { case goos == "windows" && service && !admin: return fmt.Errorf("the console at %s did not take this account's key (%s): it belongs to the %s service, "+ "which this account opens through the service, not with a key of its own. Update the bastion "+ "(run the installer again), or open the console from an Administrator terminal: privatae-bastion ui", addr, status, winServiceName) case goos == "windows": return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that "+ "port. Get-Process privatae-bastion | Format-List Id, Path, StartTime shows which; `privatae-bastion "+ "status` says whether this account's own is running", addr, status) } return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that port, "+ "most likely the one installed for the whole machine. Open it from the account that owns this machine "+ "with `privatae-bastion ui`, or with `sudo -H privatae-bastion ui`", addr, status) } // cmdUI opens the local UI in a browser. It does NOT start a server: the UI // belongs to the running daemon, because that is the process that knows what is // live. func cmdUI() error { entry, addr, err := consoleEntryForThisAccount() if err != nil { return err } fmt.Printf("opening the local UI: http://%s\n", addr) if err := openBrowser(entry); err != nil { fmt.Printf("\ncould not open a browser (%v).\nPaste this into one, within a minute:\n\n %s\n\n", err, entry) } return nil } // openBrowser hands a URL to whatever the desktop uses. hideConsole matters // here for the same reason it matters everywhere else on Windows: started from // a console-less daemon or a double-click, rundll32 would flash a window // (console_windows.go). func openBrowser(url string) error { var cmd *exec.Cmd switch runtime.GOOS { case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) case "darwin": cmd = exec.Command("open", url) default: cmd = exec.Command("xdg-open", url) } hideConsole(cmd) return cmd.Start() } // uiDaemonListening reports whether THIS account's bastion daemon is up. It is // how `status` tells a running bastion with no Host from one that is not // running: with no pairing there is no pairing listener to probe. // // A port answering is not enough. ui.addr can outlive its daemon, and the port // it names can then belong to another account's bastion (the system service // beside a person's own, say), which would read as "running" when this one is // not. So the console must accept this account's own ui.secret, the same proof // `ui` uses (localUIEntryURLTo). The ticket that proves it lapses unused after // uiTicketTTL. func uiDaemonListening() bool { p, err := uiAddrPath() if err != nil { return false } b, err := os.ReadFile(p) if err != nil { return false } addr := strings.TrimSpace(string(b)) if !bastionListening(addr) { return false } sp, err := uiSecretPath() if err != nil { return false } sb, err := os.ReadFile(sp) if err != nil { return false // a daemon that ran under this state would have written it } req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/auth/ticket", nil) if err != nil { return false } req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(sb))) resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req) if err != nil { return false } _ = resp.Body.Close() return resp.StatusCode == http.StatusOK }