package main // uidevice.go — the approvals popup: what your hosts are waiting on you for, // opened from the taskbar instead of a terminal. // // THE GAP IT CLOSES. On the machine that is the person's approval device // (device.go), a host files an ask and it lands in device_requests.json. Until // now the only way to read it was `device-approvals` and the only way to answer // it was `device-approve` — commands typed at a console the person had to know // to open. A fleet update is one ask per machine, so the person who asked for // a fleet of approvals was handed a terminal. An approval surface you have to go // looking for is a poll, not a prompt. // // WHAT IS HERE. The page (/approvals) and its fragment list, in plain words, // every ask waiting on this device plus the step-up requests waiting on this // machine itself — everything that is waiting on the person, in one place. Each // device ask has Approve and Deny; several have "Approve all shown". The tray // (tray_windows.go) and the notification (notify_windows.go) open this page // directly, as a small app window where the system has one (uipopup_windows.go), so a // waiting approval is one click from the taskbar with no menu in between. // // WHAT IS NOT CHANGED. Signing is deviceApprove's, exactly as the console does // it: the same key, the same purpose the device derived itself when the ask was // filed, one use, an hour at most. The page adds a way to press the button; it // adds no way to sign anything the console could not. import ( "encoding/json" "fmt" "net/http" "os" "strings" ) // uiDeviceApproval is one ask waiting on this approval device. type uiDeviceApproval struct { ID string // full id; the forms post this Short string // the console's [xxxxxxxx] Server string // the machine the approval will run on — the purpose names it At string Lines []string // the device's own words (describeRequest), one per line // A ship plan (device_plan.go); NeedsCode: it runs root work, so it is // approved one at a time with the person's code, never by "all shown". Plan bool NeedsCode bool } // uiDeviceNoticeID prefixes a device ask's id in the notifier's memory, so it // can never collide with a step-up request id from a pairing. const uiDeviceNoticeID = "device:" // isApprovalDevice: this machine holds a device key (device-init ran here). // // It reads the PUBLIC half only. It runs on every tray tick and every page // render, and there is no reason for either to hold the private key in memory; // deviceSign is the one place that loads it, at the moment it signs. func isApprovalDevice() bool { p, err := deviceKeyPath() if err != nil { return false } raw, err := os.ReadFile(p) if err != nil { return false } var pub struct { Pub string `json:"pub"` } return json.Unmarshal(raw, &pub) == nil && pub.Pub != "" } // pendingDeviceApprovals is every ask waiting on this device, oldest first — // the same order and the same filter as `device-approvals` prints. // // A machine that is not a device returns nothing and never reads the file: the // device lane answers only on a machine that holds the key, so a stray file // elsewhere is not an ask anyone can answer. func pendingDeviceApprovals() []uiDeviceApproval { if !isApprovalDevice() { return nil } deviceMu.Lock() m := deviceRequestsLoad() deviceMu.Unlock() var out []uiDeviceApproval for _, id := range sortedDeviceRequests(m) { r := m[id] if r.State != "pending" { continue } short := id if len(short) > 8 { short = short[:8] } var lines []string for _, l := range strings.Split(r.Words, "\n") { if strings.TrimSpace(l) != "" { lines = append(lines, l) } } out = append(out, uiDeviceApproval{ ID: id, Short: short, Server: r.Server, At: uiWhen(r.At), Lines: lines, Plan: r.Plan, NeedsCode: r.NeedsCode, }) } return out } // approvalsForNotice is the one pending list the notification and the tray // count: the step-up requests on this machine's pairings and the asks waiting // on this device. They answer different questions (a code here vs a tap here) // but to the person they are the same thing — something is waiting on them — // and the popup shows both. func approvalsForNotice(s uiStatus) []uiApproval { out := append([]uiApproval(nil), s.Approvals...) for _, d := range s.DeviceApprovals { lines := d.Lines if len(lines) == 0 { lines = []string{"A host is asking your approval for " + d.Server + "."} } out = append(out, uiApproval{ID: uiDeviceNoticeID + d.ID, Short: d.Short, At: d.At, Lines: lines}) } return out } // ── routes ────────────────────────────────────────────────────────────────── func (u *uiServer) deviceRoutes(mux *http.ServeMux) { mux.HandleFunc("/approvals", u.guarded(u.approvalsPage)) mux.HandleFunc("/fragments/approvals", u.guarded(u.approvalsFragment)) mux.HandleFunc("/fragments/approvals-news", u.guarded(u.approvalsNews)) mux.HandleFunc("/device/approve", u.adminOnly(u.deviceApproveOne)) mux.HandleFunc("/device/deny", u.guarded(u.deviceDenyOne)) mux.HandleFunc("/device/approve-shown", u.adminOnly(u.deviceApproveShown)) } func (u *uiServer) approvalsPage(w http.ResponseWriter, r *http.Request) { u.renderApprovals(w, "approvals.html", "", "") } func (u *uiServer) approvalsFragment(w http.ResponseWriter, r *http.Request) { u.renderApprovals(w, "approvals", "", "") } // renderApprovals is renderWith for the popup: the same fresh read of the // machine, marked so the shared step-up cards post back into THIS page. func (u *uiServer) renderApprovals(w http.ResponseWriter, name, flash, class string) { u.renderStatus(w, name, flash, class, true) } func postOnly(w http.ResponseWriter, r *http.Request) bool { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return false } return true } func (u *uiServer) deviceApproveOne(w http.ResponseWriter, r *http.Request) { if !postOnly(w, r) { return } id := strings.TrimSpace(r.FormValue("id")) server := deviceAskServer(id) if _, err := deviceApproveWithCode(id, strings.TrimSpace(r.FormValue("code"))); err != nil { u.renderApprovals(w, "approvals", "Not approved: "+err.Error(), "bad") return } if deviceIsPlan(id) { u.renderApprovals(w, "approvals", "Approved. Each of "+server+ " runs its own steps of it, once, within its time.", "ok") return } u.renderApprovals(w, "approvals", "Approved. It may run once on "+server+ " — your host picks it up within a minute.", "ok") } func (u *uiServer) deviceDenyOne(w http.ResponseWriter, r *http.Request) { if !postOnly(w, r) { return } id := strings.TrimSpace(r.FormValue("id")) server := deviceAskServer(id) if err := deviceDeny(id); err != nil { u.renderApprovals(w, "approvals", "Not denied: "+err.Error(), "bad") return } u.renderApprovals(w, "approvals", "Denied. Nothing runs on "+server+ ", and your host cannot ask for this again for a day.", "ok") } // deviceApproveShown signs every ask the page SHOWED — the ids it rendered into // the form — that is still pending, and nothing else. // // This is `device-approve all`'s rule carried over: a host can file an ask at // any moment, and "all" must never sign one that arrived after the person read // the list. The page posts the ids it drew; an ask filed since is not among // them, is left pending, and is named so the person looks again. func (u *uiServer) deviceApproveShown(w http.ResponseWriter, r *http.Request) { if !postOnly(w, r) { return } if err := r.ParseForm(); err != nil { u.renderApprovals(w, "approvals", "Not approved: the form could not be read.", "bad") return } shown := map[string]bool{} for _, id := range r.PostForm["id"] { if id = strings.TrimSpace(id); id != "" { shown[id] = true } } if len(shown) == 0 { u.renderApprovals(w, "approvals", "Nothing was shown, so nothing was approved.", "warn") return } approved, later, gone, coded := 0, 0, 0, 0 for _, a := range pendingDeviceApprovals() { if !shown[a.ID] { later++ continue } if a.NeedsCode { // Root work is approved one at a time, with the code. coded++ continue } if _, err := approveShownOne(a.ID); err != nil { // Answered at the console, or lapsed, between the list being read // and this signature: it is no longer anyone's to approve, and the // rest of what the person was shown still is. Anything else (the // key could not sign) is a real failure and stops here. if deviceStateOf(a.ID) != "pending" { gone++ continue } u.renderApprovals(w, "approvals", fmt.Sprintf("Approved %d, then stopped: %v", approved, err), "bad") return } approved++ } msg := fmt.Sprintf("Approved %d. Each may run once — your host picks them up within a minute.", approved) class := "ok" if approved == 0 { msg, class = "Nothing you were shown is still waiting.", "warn" } if gone > 0 { msg += fmt.Sprintf(" %d had already been answered elsewhere.", gone) } if later > 0 { msg += fmt.Sprintf(" %d arrived after you looked and were NOT approved — read them below.", later) class = "warn" } if coded > 0 { msg += fmt.Sprintf(" %d ship plan(s) run root work and wait for your code, one at a time.", coded) class = "warn" } u.renderApprovals(w, "approvals", msg, class) } // approveShownOne is deviceApprove, named so a test can put an answer from the // console between the list being read and one ask being signed — the window // the "answered elsewhere" branch exists for. Nothing else ever replaces it. var approveShownOne = deviceApprove // NoCodeApprovals is how many shown asks "approve all shown" signs: every // one but a ship plan that runs root work, which needs the code. func (s uiStatus) NoCodeApprovals() int { n := 0 for _, a := range s.DeviceApprovals { if !a.NeedsCode { n++ } } return n } // deviceIsPlan: the ask is a ship plan. func deviceIsPlan(id string) bool { deviceMu.Lock() defer deviceMu.Unlock() return deviceRequestsLoad()[id].Plan } // deviceStateOf reads one ask's state under the lock ("" when unknown). func deviceStateOf(id string) string { deviceMu.Lock() defer deviceMu.Unlock() return deviceRequestsLoad()[id].State } // ── the news poll ─────────────────────────────────────────────────────────── // approvalsNews is the ONLY thing on the approvals page that updates by itself. // // WHY THE LIST DOES NOT. A list that redraws on a timer redraws under the // person: a card answered elsewhere shifts the next one's Approve under the // cursor, a half-typed code is wiped, and — the one that matters — the "approve // all" form is re-drawn with an ask that landed a second ago, so "all shown" // would sign something nobody read. So the list is drawn only when the person // acts (an answer, or "show them"), and this poll only SAYS that something // changed. It is handed the ids the list was drawn with and compares. func (u *uiServer) approvalsNews(w http.ResponseWriter, r *http.Request) { shown := map[string]bool{} for _, id := range r.URL.Query()["shown"] { shown[id] = true } s := u.status() now := map[string]bool{} for _, a := range s.DeviceApprovals { now[a.ID] = true } for _, a := range s.Approvals { now[a.ID] = true } arrived, left := 0, 0 for id := range now { if !shown[id] { arrived++ } } for id := range shown { if !now[id] { left++ } } 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") data := struct{ Arrived, Left int }{arrived, left} if err := u.tpl.ExecuteTemplate(w, "approvals-news", data); err != nil { uilogf("render approvals-news: %v\n", err) } } // deviceAskServer names the machine an ask is for, for the outcome line. Read // before the answer is recorded; "" when the id is unknown (the answer will say // so). func deviceAskServer(id string) string { deviceMu.Lock() defer deviceMu.Unlock() if r, ok := deviceRequestsLoad()[id]; ok && r.Server != "" { return r.Server } return "that machine" } // uiApprovalsPath is the one place a sign-in ticket may land other than "/". // root() redirects only to paths in uiLandingPaths — a ticket link is handed to // a browser, and an open redirect on it would be a phishing tool pointed at the // person's own machine. const uiApprovalsPath = "/approvals" var uiLandingPaths = map[string]bool{uiApprovalsPath: true} // uiLanding is where a redeemed ticket goes: the requested page if it is one we // name, else the front page. func uiLanding(to string) string { if uiLandingPaths[to] { return to } return "/" }