bastion 0.53.2
This commit is contained in:
@@ -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 + ")"
|
||||
}
|
||||
Reference in New Issue
Block a user