Files
privatae-bastion/client-go/mode.go
T
Privatae 48899b41a9
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
bastion 0.63.1
2026-11-03 22:56:18 +00:00

138 lines
4.9 KiB
Go

package main
// Box mode — local or networked. One binary, two shapes.
//
// The bastion that existed before this file is the LOCAL bastion: one person,
// one host, and on a home machine it runs as that person, not root. The
// NETWORKED bastion is the same binary on a machine several people share: it
// runs as root (the ops it will grow need root), it serves many hosts, and
// every host after the first is held pending until the anchor host approves
// it (anchor.go).
//
// Why a mode at all, instead of inferring the shape from the pairing count:
// the two differ in what `pair` is ALLOWED to do. A local box refuses a second
// pairing outright — that is the whole protection for a single-user machine,
// where a second host appearing means someone else got a token onto your
// box. A networked box accepts it and parks it pending. The box has to know
// which rule it is enforcing before the second token arrives, so the choice
// is made at install and written down, never guessed.
//
// The systemd unit the installer writes (deploy/install.sh) has no User= and
// HOME=/root, so every install made through it already runs as root. Existing
// installs default to LOCAL here regardless, because a mode that silently
// widened what `pair` accepts on every machine already installed would be the
// takeover hole this work exists to close. Root + local is fine;
// non-root + networked is refused (set-mode needs root).
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
modeLocal = "local"
modeNetworked = "networked"
)
// modePath is <configDir>/mode.json — beside owner.json and anchor.json, NOT
// inside pairings/: the mode belongs to the box, not to any host it serves.
func modePath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "mode.json"), nil
}
type modeFile struct {
Mode string `json:"mode"`
}
// boxMode returns the box's mode. Absent, unreadable or unrecognised all read
// as LOCAL: the narrower rule is the safe default, and it is exactly what
// every install that predates the mode file was doing.
func boxMode() string {
p, err := modePath()
if err != nil {
return modeLocal
}
raw, err := os.ReadFile(p)
if err != nil {
return modeLocal
}
var f modeFile
if json.Unmarshal(raw, &f) != nil {
return modeLocal
}
if f.Mode == modeNetworked {
return modeNetworked
}
return modeLocal
}
// setBoxMode writes the mode. Networked needs root because the networked shape
// is root-owned by definition — a non-root user flipping a shared
// machine to "accepts more pairings" is the thing the mode prevents.
func setBoxMode(mode string) error {
mode = strings.TrimSpace(mode)
if mode != modeLocal && mode != modeNetworked {
return fmt.Errorf("mode must be %q or %q", modeLocal, modeNetworked)
}
if mode == modeNetworked {
if err := requireMachineAuthority("set-mode networked"); err != nil {
return err
}
}
if mode == modeLocal {
// Going back to local must not strand hosts: a local box serves a second
// host only on a yes typed at this machine (pair_terminal.go), and these
// were paired under the networked rules, so with several pairings the box
// would serve hosts nobody here said yes to.
cfgs, err := loadPairings()
if err == nil && len(cfgs) > 1 {
return fmt.Errorf("cannot switch to local mode: %d hosts are paired, and a local bastion adds a second host only on a yes typed at this machine — remove the others first, then pair them again here", len(cfgs))
}
if currentAnchor() != nil {
return errors.New("cannot switch to local mode: this box is anchored (anchor.json) — anchoring is a networked-mode concept")
}
}
p, err := modePath()
if err != nil {
return err
}
b, err := json.MarshalIndent(modeFile{Mode: mode}, "", " ")
if err != nil {
return err
}
if err := atomicWrite(p, b); err != nil {
return err
}
// The marker carries the mode, so it moves with it (singleton.go). Also the
// point at which a networked box first claims the machine: `set-mode
// networked` is root by definition, and install.sh runs it before anything is
// paired.
_ = maybeWriteMarker()
return nil
}
// cmdSetMode is `privatae-bastion set-mode <local|networked>`; install.sh
// --networked calls it as root at install time.
func cmdSetMode(mode string) error {
if err := setBoxMode(mode); err != nil {
return err
}
fmt.Printf("mode set to %s\n", mode)
if mode == modeNetworked {
// Platform-correct, for the same reason the refusal text is: telling a
// Windows user to run `sudo` is how a real instruction becomes noise.
fmt.Println(" Next: pair it with the Host that should be in charge of it. That Host takes charge as it is")
fmt.Println(" paired (a Host on this machine always does; so does one an organisation's policy names), and every")
fmt.Println(" other Host waits until it admits it. To put a Host already paired in charge: " + anchorCommandHint())
}
return nil
}