bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+557
View File
@@ -0,0 +1,557 @@
package main
// ONE BASTION PER MACHINE.
//
// The gap this closes is not "the installer has no check". It is that NOTHING in
// this binary was machine-scoped: mode, anchor, owner and pairings all live under
// one OS user's config dir, so the anchor governed a DIRECTORY, not a machine. A
// second OS user installs non-root into $HOME/.local/bin, gets their own
// configDir(), pairs their own host, and the anchor never learns of it. They never
// need root, because they never need networked mode — local mode serves exactly one
// host, which is all they want. peers.go cannot see it: it reports peers read from
// the pairings its OWN daemon loaded, so a bastion under another user is
// structurally unseeable.
//
// Two instruments, and the difference between them is the honest part:
//
// - a MARKER that makes the cooperating case refuse, with a message saying what
// to do instead. A non-root user cannot be stopped from running a binary, and a
// binary that honours a marker can be swapped for an older one or run with
// HOME=/tmp/x. A marker binds only a COOPERATING binary. Nothing here claims to
// prevent a determined local user from running their own copy.
// - an exclusion LOCK that makes two daemons impossible rather than merely
// unlucky.
//
// The non-cooperating case is a different instrument that already exists: the
// anchored bastion runs as root and walks /proc with per-process UID
// (agents_linux.go), so a second bastion is VISIBLE even when it cannot be refused.
//
// On the lock, and why it is a lock: there is ALREADY an accidental singleton and
// it is the worst kind. nextListenAddr allocates from 127.0.0.1:7878 upward
// consulting only the pairings handed to it (config.go), which are this user's — so
// user B's first pairing also gets 7878. runPairing pushes ListenAndServe into errc
// and cmdRun returns on the first errc, so the process exits; under Restart=always
// the loser crash-loops forever and the winner is decided by boot order. This
// replaces an accidental singleton that fails as a bind error with a deliberate one
// that says why.
//
// SCOPE, stated explicitly: step 1 is LINUX-ROOT ONLY.
// Nothing writes the marker on a non-root install, and the lock is root-owned by
// design. On macOS and Windows there is no marker or machine lock — for two
// non-root users on one Linux box there is none either. Where those platforms
// run the bastion as a system service, sysservice.go refuses `pair` and `run`
// from an account that does not administer the machine, which covers the
// cooperating case the marker covers here. markerPath knows their paths for
// when the work reaches them; it does not claim they are covered.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
// machineMarker is the machine-scoped record. Root-owned and WORLD-READABLE on
// purpose: a non-root `pair` has to be able to read it, or the second-user refusal
// — the case the marker exists for — never fires.
type machineMarker struct {
BoxPublicKey string `json:"box_public_key,omitempty"`
Mode string `json:"mode"`
AnchorHandle string `json:"anchor_handle,omitempty"`
StateDir string `json:"state_dir"`
BinaryPath string `json:"binary_path,omitempty"`
WrittenAt string `json:"written_at"`
}
// markerDir / markerName: the machine-scoped location, named for the PRODUCT
// rather than the binary.
//
// That is not cosmetic. The binary name and the config directory name are
// allowed to change independently of each other over the life of an install;
// a marker keyed on either would split an installed base in two the day one of
// them moved. The product name is the one thing that stays.
//
// WHY THIS IS NOT /etc/privatae/bastion.json, which was the first choice.
//
// /etc/privatae is a SECRETS directory on the boxes that have one, and such a
// directory is normally 0750 rather than 0755. The marker must be WORLD-READABLE
// under a TRAVERSABLE directory or a non-root `pair` cannot read it and the
// second-user refusal never fires — so putting it there forces one of two bad
// outcomes: chmod a hardened secrets directory to 0755 as a side effect of a
// bastion write (which the first draft did, silently), or leave it 0750 and have
// the guard fail open on exactly those boxes, because ReadFile returns EACCES,
// which is not IsNotExist, and a guard that treated unreadable as absent would
// let that pairing through.
//
// Neither is acceptable, so the marker lives in a directory the bastion CREATES
// AND OWNS. /var/lib/privatae is already the bastion's root-install base
// (faStateBase, freeagent_drop.go). The rule that matters — name it for the
// PRODUCT, not the binary — still holds.
//
// The marker stores the state directory as an absolute path. A marker whose
// directory no longer exists protects nothing: there is no second bastion at
// that path to collide with, only a record of one that was moved or removed.
// Such a marker is treated as absent by both the rewrite rule and the pairing
// guard, so a machine whose state directory was renamed or deleted can pair
// again instead of refusing forever with advice that cannot be followed. Only
// a directory that is positively gone counts; one this user merely cannot
// traverse into is still somebody's bastion and still refuses.
const (
markerDirLinux = "/var/lib/privatae/bastion"
markerDirDarwin = "/Library/Application Support/privatae/bastion"
markerName = "machine.json"
)
// markerPathOverride / runtimeDirOverride relocate the two machine-scoped paths
// for tests. Same shape as faStateBaseOverride in freeagent_drop.go. Nothing
// outside a test may set them.
var markerPathOverride, runtimeDirOverride string
func markerPath() string {
if markerPathOverride != "" {
return markerPathOverride
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(markerDirDarwin, markerName)
case "windows":
base := os.Getenv("ProgramData")
if base == "" {
base = `C:\ProgramData`
}
return filepath.Join(base, "privatae", "bastion", markerName)
default:
return filepath.Join(markerDirLinux, markerName)
}
}
// singletonSupported reports whether this platform has the machine-scoped half at
// all. Step 1 is Linux only (see the scope note above).
func singletonSupported() bool { return runtime.GOOS == "linux" }
// readMarker returns the machine marker, or nil when there is none. A missing
// marker is not an error: marker absent ⇒ exactly today's behaviour, which is what
// makes this safe to roll onto live installs.
func readMarker() (*machineMarker, error) {
raw, err := os.ReadFile(markerPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var m machineMarker
if err := json.Unmarshal(raw, &m); err != nil {
// A corrupt marker must not brick pairing. Report it; the call sites treat
// it as absent. Refusing on unparseable bytes would be a self-inflicted
// outage with no upside.
return nil, fmt.Errorf("machine marker at %s is unreadable: %w", markerPath(), err)
}
if strings.TrimSpace(m.StateDir) == "" {
return nil, nil
}
return &m, nil
}
// writeMarker writes the marker with EXPLICIT 0755 dir / 0644 file.
//
// It deliberately does NOT use atomicWrite: that helper makes the dir 0700 and the
// file 0600 (ctx.go), which a non-root `pair` could not read — and then the
// second-user refusal, the entire point, would never fire. Same temp+rename shape,
// different modes, and an explicit Chmod because the process umask would otherwise
// decide.
func writeMarker(m *machineMarker) error {
if err := refuseRealMachineWrite(markerPathOverride, "machine marker"); err != nil {
fmt.Printf("singleton: %v\n", err)
return nil
}
path := markerPath()
dir := filepath.Dir(path)
// Create the directory if it is ours to create; NEVER chmod one that already
// exists. The first draft chmod'd unconditionally, which where this resolved
// into a hardened directory would have loosened it as a side effect of a
// bastion write.
//
// faMkdirAllMode, not MkdirAll: MkdirAll applies the process UMASK to every
// ANCESTOR it creates, and only the leaf was being fixed. /var/lib/privatae
// was absent on 19 of 20 boxes measured, so the first marker write creates it
// almost everywhere — and under umask 027 or 077 (a `sudo -H set-mode` from a
// hardened shell; sudo passes the umask through) that parent lands 0750 or
// 0700. A non-root `pair` then cannot traverse to the marker, ReadFile
// returns EACCES, and the guard would fail open on exactly the locked-down
// boxes this location was chosen for. faMkdirAllMode records the ancestors it
// creates and sets each to 0755 explicitly; it was written for this same
// parent, for this same reason (freeagent_drop.go).
if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
if err := faMkdirAllMode(dir, 0o755); err != nil {
return err
}
} else if statErr != nil {
return statErr
} else if fi, lerr := os.Lstat(dir); lerr == nil && fi.Mode()&os.ModeSymlink != 0 {
// Symmetry with the lock's check: a symlinked marker dir means the marker
// is not where we think it is.
return fmt.Errorf("%s is a symlink — refusing to write the machine marker through it", dir)
}
m.WrittenAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
f, err := os.CreateTemp(dir, ".bastion-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp) // no-op once renamed away
if err := f.Chmod(0o644); err != nil {
_ = f.Close()
return err
}
if _, err := f.Write(append(data, '\n')); err != nil {
_ = f.Close()
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
return nil
}
// warnIfMarkerUnreachable says so, loudly, when a non-root process could not
// traverse to the marker. Without this the failure is silent in the worst
// direction: singletonGuardForPair treats an unreadable marker as absent and
// ALLOWS the pairing, so a locked-down ancestor turns the singleton off on the
// machines that are most hardened, with nothing in any log.
func warnIfMarkerUnreachable(path string) {
for d := filepath.Dir(path); d != "/" && d != "."; d = filepath.Dir(d) {
fi, err := os.Stat(d)
if err != nil {
return
}
if fi.Mode().Perm()&0o001 == 0 {
fmt.Printf("singleton: WARNING %s is %v — a non-root `pair` cannot reach the machine marker, "+
"so the second-bastion refusal will NOT fire on this box\n", d, fi.Mode().Perm())
return
}
}
}
// maybeWriteMarker records this process's state dir as the machine's canonical one.
// Called on root `run`, on `set-mode networked`, and on `claim-anchor`. Idempotent.
//
// The REWRITE IS CONDITIONAL, and that condition is load-bearing. An unconditional
// rewrite plus the lock means whichever root daemon wins after a primary dies can
// repoint the canonical state dir at its own: a run from a forked dir
// (XDG_CONFIG_HOME, or HOME in sudo's env_keep) that acquires the lock first would
// flip the marker, and the unit's restart seconds later would find ITSELF the
// standby. Without any rewrite at all, a future config-dir rename would refuse
// every pair on the fleet until someone hand-fixed it — so: rewrite when the marker
// names this same dir, or when there is no marker, and never otherwise.
func maybeWriteMarker() string {
if !singletonSupported() {
return "not supported on this platform"
}
if !processIsRoot() {
return "not root"
}
// (the test guard now lives in writeMarker itself — see refuseRealMachineWrite)
// Historical note, because it was a real incident on a real box:
// the first run of this suite as root wrote /etc/privatae/bastion.json naming
// a t.TempDir() that no longer exists — which would have made this box refuse
// every future `pair`, pointing at a directory nobody can reach. The test that
// did it only called setBoxMode; it never mentioned the marker, and it never
// will. Relying on each test to remember an override is how that happens, so
// the binary refuses instead: under `go test`, write only where a test has
// explicitly pointed the marker.
mine, err := configDirPath()
if err != nil {
return "no config dir"
}
existing, err := readMarker()
if err != nil {
fmt.Printf("singleton: %v — not rewriting it\n", err)
return "unreadable marker"
}
if !markerShouldBeWritten(existing, mine) {
// Another state dir owns this machine. Say so once; do not fight over it.
fmt.Printf("singleton: this machine's bastion state is %s, not %s — leaving the marker alone\n",
existing.StateDir, mine)
return "another state dir owns this machine"
}
m := &machineMarker{StateDir: mine, Mode: boxMode()}
if exe, e := os.Executable(); e == nil {
m.BinaryPath = exe
}
if a := currentAnchor(); a != nil {
m.AnchorHandle = a.AnchorHandle
m.BoxPublicKey = a.PublicKey
}
// Check reachability on EVERY pass, not only when we write. An ancestor
// chmod'd strict after the first write would otherwise never be reported —
// it would just fail closed at `pair` with nothing in the journal.
warnIfMarkerUnreachable(markerPath())
// Only write when something actually changed. A machine that is crash-looping
// unpaired runs this every two seconds; an unconditional write is a
// CreateTemp + fsync + rename each pass for no change.
if existing != nil && sameDir(existing.StateDir, m.StateDir) && existing.Mode == m.Mode &&
existing.AnchorHandle == m.AnchorHandle && existing.BinaryPath == m.BinaryPath {
return "unchanged"
}
if err := writeMarker(m); err != nil {
fmt.Printf("singleton: could not write %s: %v\n", markerPath(), err)
return "write failed"
}
if _, err := os.Stat(markerPath()); err != nil {
return "refused" // the test guard declined; writeMarker returns nil softly
}
return "written"
}
// markerShouldBeWritten is the conditional-rewrite rule, extracted because it is
// the load-bearing one: write when there is NO marker, or when the marker already
// names this same state dir — never when it names another. Extracted so the rule
// is testable on its own rather than only reachable as root through maybeWriteMarker.
func markerShouldBeWritten(existing *machineMarker, mine string) bool {
return existing == nil || sameDir(existing.StateDir, mine) || markerStateDirGone(existing)
}
// markerStateDirGone reports a marker whose state directory is positively
// absent. Permission errors are not absence: a directory this user cannot look
// into is still a bastion that the guard exists to protect.
func markerStateDirGone(m *machineMarker) bool {
if m == nil || m.StateDir == "" {
return false
}
_, err := os.Stat(m.StateDir)
return err != nil && os.IsNotExist(err)
}
// refuseRealMachineWrite stops a TEST from claiming the real machine.
//
// A test must never write the machine marker or take the machine lock at their
// real paths. This is not hypothetical: the first run of this suite as root wrote
// a real marker on the build box naming a t.TempDir() that no longer existed,
// which would have made that machine refuse every future `pair`, pointing at a
// directory nobody can reach. The test responsible only called setBoxMode; it
// never mentioned the marker, and a future one will not either.
//
// It guards the two WRITE paths rather than the one caller, because a test that
// calls writeMarker or acquireSingletonLock directly would otherwise slip past.
// Reads are harmless and are not guarded.
func refuseRealMachineWrite(override, what string) error {
if testing.Testing() && override == "" {
return fmt.Errorf("refusing to write the real %s from a test — point it at a temp path first", what)
}
return nil
}
// sameDir compares two state-dir paths the way the filesystem would.
func sameDir(a, b string) bool {
return filepath.Clean(strings.TrimSpace(a)) == filepath.Clean(strings.TrimSpace(b))
}
// errForkedState is returned when this process's state dir is not the machine's.
var errForkedState = errors.New("another bastion owns this machine")
// singletonGuardForPair refuses a pairing that would fork this machine's bastion
// state into a second directory.
//
// It is called at the TOP of pairWithToken, before loadPairings, and the ordering
// is the whole point: loadPairings -> pairingsDir -> MkdirAll CREATES the forked
// directory, so a check placed after it has already done the damage it exists to
// prevent. It uses configDirPath (no MkdirAll) for the same reason.
//
// Marker absent ⇒ nil ⇒ exactly today's behaviour.
func singletonGuardForPair() error {
if !singletonSupported() {
return nil
}
m, err := readMarker()
if err != nil {
return singletonGuardForPairFromReadError(err)
}
if m == nil {
return nil
}
return singletonGuardAgainst(m)
}
// singletonGuardForPairFromReadError is the "we could not read the marker" half,
// split out so the EACCES-refuses / corrupt-allows decision is testable without
// dropping privileges inside the test process.
func singletonGuardForPairFromReadError(err error) error {
// UNREADABLE IS NOT ABSENT. "Marker absent ⇒ today's behaviour" means
// ENOENT, and readMarker already maps that to (nil, nil). Anything else —
// EACCES from a locked-down ancestor, a truncated file — means we cannot
// tell, and this directory is created only by the bastion, so something here
// owns the machine. Allowing was the first draft's behaviour and it failed
// open on precisely the hardened boxes.
if os.IsPermission(err) || errors.Is(err, os.ErrPermission) {
return fmt.Errorf("%w.\n"+
" this machine has a bastion marker at %s that this user cannot read (%v).\n"+
" A machine runs one bastion: pair as that bastion's user, or with `sudo -H`",
errForkedState, markerPath(), err)
}
// A CORRUPT file is different: refusing every pairing because one file got
// truncated would be a self-inflicted outage with no upside.
fmt.Printf("singleton: %v — allowing this pairing\n", err)
return nil
}
// singletonGuardAgainst compares a marker we DID read against this process.
func singletonGuardAgainst(m *machineMarker) error {
mine, err := configDirPath()
if err != nil {
return nil
}
if sameDir(m.StateDir, mine) || markerStateDirGone(m) {
return nil
}
// Both dirs in the message on purpose: a legitimate `sudo -H pair` refused
// because root's profile carries an XDG_CONFIG_HOME needs to see WHY, and
// "already paired" would send the reader hunting in the wrong place.
return fmt.Errorf(
"%w.\n"+
" this machine's bastion keeps its state in: %s\n"+
" this command would write a SECOND one in: %s\n"+
"\n"+
"A machine runs one bastion. If you are the owner, run this as that\n"+
"bastion's user (or with `sudo -H`) so it pairs into the existing one.\n"+
"If someone else owns the bastion here, this machine cannot serve your\n"+
"host yet — say so to whoever runs it.",
errForkedState, m.StateDir, mine)
}
// ── exclusion: the deliberate singleton ─────────────────────────────────────
// runtimeDir holds the lock. Root-owned 0755 so only root can create or remove
// anything in it, which is why the lock cannot be squatted.
const runtimeDir = "/run/privatae"
func runtimeDirPath() string {
if runtimeDirOverride != "" {
return runtimeDirOverride
}
return runtimeDir
}
func lockPath() string { return filepath.Join(runtimeDirPath(), "bastion.lock") }
// singletonLock is a held exclusion lock. Closing the file releases it; the kernel
// does that on death too, including SIGKILL and OOM, so there is nothing to clean up
// and no stale state to reason about.
type singletonLock struct{ f *os.File }
func (l *singletonLock) release() {
if l != nil && l.f != nil {
_ = l.f.Close()
}
}
// acquireSingletonLock takes THE machine-wide exclusion lock for the daemon.
//
// 0600 AND ROOT-OWNED, and that is not tidiness: flock(2) needs no write access —
// LOCK_EX can be taken through an O_RDONLY fd. At the marker's 0644 any local uid
// could take this lock and hold the root daemon out, which is precisely the squat
// this design rejected abstract sockets for. A `nobody` process holding it across a
// restart would leave the unit active and blocked forever with nothing in the log.
//
// The file is NEVER unlinked. Unlink-and-recreate lets two daemons hold LOCK_EX on
// two different inodes at one path; /run/privatae being root 0755 means only root
// can do it, so the invariant is cheap — and writing it down is what stops a future
// "clean up stale files" change from reintroducing the race the lock replaced.
//
// Blocking vs non-blocking: blocking is right under systemd (the unit is
// Type=simple with no TimeoutStartSec, so a blocked start is not killed, and
// LOCK_NB-then-exit would be the two-second crash-loop this exists to end). It is
// wrong for a human: a manual `sudo -H privatae-bastion run` would become an
// invisible standby, `systemctl stop` would then not stop the bastion, and
// `systemctl restart` would swap the roles permanently. So: non-blocking with
// "already running" when stdin is a terminal, blocking when it is not.
func acquireSingletonLock() (*singletonLock, error) {
if !singletonSupported() || !processIsRoot() {
return nil, nil // no singleton on this platform/user in step 1
}
if err := refuseRealMachineWrite(runtimeDirOverride, "machine lock"); err != nil {
return nil, err
}
if err := os.MkdirAll(runtimeDirPath(), 0o755); err != nil {
return nil, fmt.Errorf("singleton: cannot create %s: %w", runtimeDirPath(), err)
}
// Only root can create anything in /run, so this is belt-and-braces — but a
// symlink or a non-root owner there would mean the lock is not the lock, and
// the check is one syscall.
if fi, err := os.Lstat(runtimeDirPath()); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("singleton: %s is a symlink — refusing to lock through it", runtimeDirPath())
}
if !fi.IsDir() {
return nil, fmt.Errorf("singleton: %s is not a directory", runtimeDirPath())
}
if uid, ok := ownerUID(fi); ok && uid != 0 {
return nil, fmt.Errorf("singleton: %s is owned by uid %d, not root — refusing to lock in it", runtimeDirPath(), uid)
}
}
_ = os.Chmod(runtimeDirPath(), 0o755)
f, err := os.OpenFile(lockPath(), os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("singleton: cannot open %s: %w", lockPath(), err)
}
if err := os.Chmod(lockPath(), 0o600); err != nil {
_ = f.Close()
return nil, fmt.Errorf("singleton: cannot secure %s: %w", lockPath(), err)
}
if stdinIsTerminal() {
if err := flockFileNB(f); err != nil {
holder := lockHolder(f)
_ = f.Close()
return nil, fmt.Errorf("a bastion is already running on this machine%s.\n"+
" Stop it first (`systemctl stop privatae-bastion`), or leave it running —\n"+
" one machine runs one bastion, and it already serves every pairing here", holder)
}
} else {
fmt.Printf("singleton: taking the machine lock at %s (waits if another bastion holds it)\n", lockPath())
if err := flockFile(f); err != nil {
_ = f.Close()
return nil, fmt.Errorf("singleton: cannot lock %s: %w", lockPath(), err)
}
}
// Record who holds it. Truncate-and-write, never unlink.
if err := f.Truncate(0); err == nil {
_, _ = f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0)
_ = f.Sync()
}
return &singletonLock{f: f}, nil
}
// lockHolder reads the pid recorded by the daemon that holds the lock, for the
// message. Best-effort: an empty or unreadable file just means a less specific
// sentence, never a failure.
func lockHolder(f *os.File) string {
buf := make([]byte, 32)
n, _ := f.ReadAt(buf, 0)
pid := strings.TrimSpace(string(buf[:n]))
if pid == "" {
return ""
}
if _, err := strconv.Atoi(pid); err != nil {
return ""
}
return " (pid " + pid + ")"
}