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 + ")" }