Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

139 lines
5.2 KiB
Go

package main
// contextui.go — `privatae-bastion context`: YOUR files, on your machine, in a
// process that runs as YOU.
//
// WHY THIS IS A SECOND PROCESS AND NOT A PAGE ON THE DAEMON'S UI. The daemon's
// local UI is the machine-admin console: the pairing list, the anchor, the
// containment banner, the marks on automated programs, the dry-run policy. On a
// networked bastion that daemon runs as root (deploy/install.sh writes the unit
// with no User=), so its UI is root's, and that is correct — those are all
// machine-wide facts and machine-wide acts.
//
// But a person who is not the machine's admin still has to be able to look at
// their own files and hand a folder to a bastion agent. Serving that from the
// root daemon would mean root's process deciding, in our code, which files each
// caller may see — a permission model we would have to write, get right, and
// keep right, with a forged answer costing somebody their private files.
//
// So this process runs as the person instead. The walk it does is an ordinary
// walk by an ordinary user, so the only files it can possibly draw are the ones
// that user could already open in any file manager. THE KERNEL DRAWS THE
// BOUNDARY AND THIS CODE NEVER DECIDES IT — the same reason the sanction mark
// in sanction.go is keyed on the kernel's executable path rather than on
// anything a process says about itself.
//
// Two consequences worth stating out loud, because both are deliberate:
//
// - There is NO whole-machine map here. The scope does not exist in this
// surface, not because the button is hidden but because this process could
// not read other people's files if it tried.
// - The session lives in MEMORY and there is no secret file. The daemon needs
// one because its UI is opened by a different process (the `ui` verb, the
// tray); this verb serves and opens the browser itself, so a secret on disk
// would be a thing to leak in exchange for nothing.
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
// The context surface takes the sixteen ports BELOW the daemon's UI range, which
// itself sits below the per-pairing range counting up from 7878 (ui.go:58-65).
// So the three ranges can never collide however many hosts this machine serves,
// and several people signed into the same machine at once each get their own.
const (
contextUIPortHigh = 7861
contextUIPortLow = 7846
)
// cmdContextUI runs the context surface until interrupted.
func cmdContextUI() error {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return fmt.Errorf("this account has no home directory, so there are no files of " +
"your own to show. If you are trying to look at the whole machine, that is the " +
"machine's own console: run `privatae-bastion ui` as its administrator")
}
home = filepath.Clean(home)
var ln net.Listener
var port string
for p := contextUIPortHigh; p >= contextUIPortLow; p-- {
// LOOPBACK ONLY, DELIBERATELY, and the same bind the daemon's UI uses
// (ui.go:673). This page draws a person's own file names, which is the
// most detailed thing about them in this binary; a wider bind would
// publish it to their LAN. Nothing off-box is meant to reach it, and
// nothing off-box can: the bastion dials OUT to its host and never the
// other way.
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 {
return fmt.Errorf("no free port between %d and %d — something else is already "+
"using all sixteen", contextUIPortLow, contextUIPortHigh)
}
// A secret nobody else ever sees: it is minted here, used here, and dies
// with the process. It exists at all only because mintTicket compares
// against one, and one code path for both surfaces is worth more than a
// special case in the auth.
secret, err := genSecret(32)
if err != nil {
_ = ln.Close()
return err
}
srv, err := newUIServer(port, secret)
if err != nil {
_ = ln.Close()
return err
}
srv.contextUI = true
srv.contextHome = home
srv.contextLoadAsks()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
h := &http.Server{Handler: srv.handler(), ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
sh, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = h.Shutdown(sh)
}()
// The ticket is minted directly rather than over loopback, because unlike
// the `ui` verb there is no other process to ask: this one IS the server.
tok, err := srv.tickets.mint()
if err != nil {
_ = ln.Close()
return err
}
addr := ln.Addr().String()
entry := "http://" + addr + "/?k=" + tok
fmt.Printf("your context: http://%s\n", addr)
fmt.Printf("showing %s — what this can see is exactly what you can open.\n", home)
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)
}
fmt.Println("leave this running while you use the page; ctrl-c closes it.")
if err := h.Serve(ln); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}