Files
Privatae 4f8285ef18
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s
bastion 0.60.0
2026-11-02 00:10:58 +00:00

366 lines
13 KiB
Go

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 "/"
}