package main // anchor_local.go — who is in charge of this machine: one authoritative Host, // the anchor (anchor.go), and which Host that is. // // A machine needs one authoritative Host, and the code already has that: the // anchor. The anchor is still the one mechanism, with its signature and // verification model unchanged (writeAnchor signs it // under the box key; grants verify under the anchor Host's pinned key). What // this file decides is WHICH Host holds it, by three rules, strongest first: // // 1. An ORGANISATION'S POLICY, explicitly deployed to the machine, names the // Host in charge (anchorPolicy below). It is the only thing that overrides // the rule after it, and it is meant for a machine owned by someone other // than its local admin. Windows: the AnchorHost value under // HKLM\SOFTWARE\Policies\Privatae\Bastion (Group Policy, Intune). macOS: // the AnchorHost key of the managed preference domain ai.privatae.bastion // (a configuration profile). Linux: anchor_host in the root-owned // /etc/privatae-bastion/policy.json. Joining a domain is not a policy: // nothing is read from the domain itself. With a policy, claim-anchor for // any other Host is refused in words that say an organisation manages the // machine, a new pairing of another Host waits for the named one, and the // named one takes the anchor when it is paired with machine authority. // 2. A HOST ON THIS MACHINE is always the one in charge, never "whichever // paired first". Paired with machine authority it takes the anchor, from a // remote anchor too; the replaced anchor becomes an ordinary pairing that // waits for it to admit it (as claim-anchor --rotate leaves the old one), // the change is recorded in the pairing audit, and the replaced anchor is // told on one last heartbeat (anchorReplacedNote, run.go superviseOnce). // A Host on this machine that already holds the anchor keeps it: a second // one waits, as any other pairing does, and moving the anchor between two // of them is claim-anchor --rotate as before. // 3. Otherwise the first Host paired with a networked machine (cmdPair, as // before this file). // // WHAT "ON THIS MACHINE" IS (sameMachineHost). A pairing whose lanes run over // a unix socket (transport unix): the socket is a file on this machine's own // disk. Or a pairing whose lane port is a loopback address (transport tcp, // lane_addr 127.0.0.0/8, ::1 or localhost): loopback never leaves this // machine's network stack, and it is how a C-Series Host in a VM on this // computer is reached (npx cseries forwards the VM's lane ports to loopback, // packages/cseries/src/vm/common.js). Something else forwarded onto loopback // is something the machine's administrator set up, and taking the anchor needs // that administrator's authority anyway. Any other address, a LAN one // included, is another machine. The name the Host gives itself // (home.cseries.localhost) is not used: it is the Host's word, not the // machine's. // // It adds no power. With machine authority an administrator could already // claim the anchor on an unanchored box, delete anchor.json, or release an // anchor (anchor_release.go). What it changes is that the right Host is put in // charge without anyone having to know the order things were paired in. import ( "encoding/json" "fmt" "net" "os" "path/filepath" "strings" "time" ) // anchorPolicy is an organisation's deployed policy naming the Host in charge. type anchorPolicy struct { Host string // a host_name, or its handle Source string // where it was read, as a person would look for it } // readAnchorPolicy reads the policy from this platform's managed store // (anchorpolicy_*.go). A seam: the suite sets a policy without one. var readAnchorPolicy = readAnchorPolicyOS // currentAnchorPolicy is the deployed policy, or nil when there is none or it // names nothing a host name can be. func currentAnchorPolicy() *anchorPolicy { p := readAnchorPolicy() if p == nil { return nil } p.Host = strings.TrimSpace(p.Host) if !validHostName(p.Host) { return nil } return p } // policyNames says whether the policy names host (its full name or handle). func policyNames(p *anchorPolicy, host string) bool { if p == nil || host == "" { return false } return strings.EqualFold(p.Host, host) || strings.EqualFold(p.Host, handleOf(host)) || strings.EqualFold(handleOf(p.Host), host) } // orgManagedRefusal is the words for a Host the policy does not name. func orgManagedRefusal(p *anchorPolicy, who string) error { return fmt.Errorf("an organisation manages this machine: its policy (%s) names %s as the Host in charge of it, "+ "so %s cannot take charge here. Whoever manages this machine for the organisation changes that policy", p.Source, p.Host, who) } // sameMachineHost: the pairing's Host runs on this machine (see the header). func sameMachineHost(c *Config) bool { if c == nil { return false } if c.unixTransport() { return true } if !c.tcpTransport() { return false } return loopbackAddr(c.LaneAddr) } // loopbackAddr: host:port whose host is a loopback IP literal or localhost. func loopbackAddr(hostPort string) bool { h, _, err := net.SplitHostPort(strings.TrimSpace(hostPort)) if err != nil { return false } if strings.EqualFold(h, "localhost") { return true } ip := net.ParseIP(h) return ip != nil && ip.IsLoopback() } // admitter is the Host a pending pairing waits for: the anchor, or on a // machine an organisation's policy governs and nobody holds yet, the Host the // policy names. "" when nothing on this machine admits anyone. func admitter() string { if a := currentAnchor(); a != nil { return a.AnchorHost } if boxMode() == modeNetworked { if p := currentAnchorPolicy(); p != nil { return p.Host } } return "" } // pendingUnderPolicy: on a networked machine with no anchor yet, a policy // that names another Host makes a new pairing wait for that one. func pendingUnderPolicy(handle string) bool { if boxMode() != modeNetworked || currentAnchor() != nil { return false } p := currentAnchorPolicy() return p != nil && !policyNames(p, handle) } // anchorTake is what taking the anchor did, for `pair` and claim-anchor to say. type anchorTake struct { Taken bool Why string // the rule, in words Replaced string // the previous anchor, if there was one Waiting []string // pairings that now wait for the new anchor to admit them } // The rules by name, in the audit and on the replaced anchor's beat. const ( anchorWhyPolicy = "policy" anchorWhyLocal = "same_machine" anchorWhyFirst = "first_pairing" ) func anchorWhyWords(why string, c *Config, p *anchorPolicy) string { switch why { case anchorWhyPolicy: return "an organisation's policy (" + p.Source + ") names it as the Host in charge of this machine" case anchorWhyLocal: return "it runs on this machine (" + c.laneWhere() + "), and a Host on the machine is always the one in charge of it" case anchorWhyWindow: return "you opened the pairing window for it here (`pair --open`), and no Host was in charge of this machine" } return "it is the first Host paired with this networked machine" } // anchorAfterPair puts the right Host in charge after `pair` wrote cfg (see the // header). Only on a networked machine and with machine authority, which // claim-anchor needs anyway. Reports what it did. func anchorAfterPair(cfg *Config, all []*Config) (anchorTake, error) { if cfg == nil || boxMode() != modeNetworked || !machineAuthorityHeld() { return anchorTake{}, nil } prev := currentAnchor() if prev != nil && prev.AnchorHost == cfg.HostName { return anchorTake{}, nil } pol := currentAnchorPolicy() why := "" switch { case pol != nil: if !policyNames(pol, cfg.HostName) { return anchorTake{}, nil } why = anchorWhyPolicy case sameMachineHost(cfg): if prev != nil { if pc := pairingFor(all, prev.AnchorHost); pc != nil && sameMachineHost(pc) && pc.active() { // A Host on this machine already holds it: this one waits. return anchorTake{}, nil } } why = anchorWhyLocal case prev == nil && len(all) == 1: why = anchorWhyFirst default: return anchorTake{}, nil } t, err := takeAnchor(cfg, prev, why, pol, "") if err != nil { return anchorTake{}, fmt.Errorf("paired with %s, but putting it in charge of this machine failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint()) } return t, nil } // anchorWhyWindow: the person opened a pairing window for this Host at this // machine (`pair --open`), with machine authority, and nobody was in charge. const anchorWhyWindow = "pair_open" // anchorAfterWindow is anchorAfterPair for a Host paired through a window // opened at this machine (pair_window.go). The window is the person's say-so, // typed at this machine, for this one Host, so on a machine nobody is in // charge of yet that Host takes the anchor, as the first Host of a networked // machine does, whatever else is paired and in either mode. The rules above it // still come first: an organisation's policy that names another Host leaves // it waiting, and a Host on this machine wins (a paired one that is not // revoked keeps the machine for itself, even before it holds the anchor). // // A LOCAL machine has no anchor (claim-anchor and set-mode refuse one there), // so it is made networked first, which set-mode does with the same authority. // On a local machine already serving another Host the person said yes to, the // window's Host does not take it: that would leave the other one waiting. // // openerAuthority is whether the person who opened the window held machine // authority; the process committing the pairing (pair --open's own door, or // the running bastion's) must hold it too. func anchorAfterWindow(cfg *Config, all []*Config, openerAuthority bool) (anchorTake, error) { took, err := anchorAfterPair(cfg, all) if err != nil || took.Taken { return took, err } if cfg == nil || !openerAuthority || !machineAuthorityHeld() || currentAnchor() != nil || cfg.invalid != "" || cfg.State == pairingRevoked { return anchorTake{}, nil } pol := currentAnchorPolicy() why := anchorWhyWindow if pol != nil { if !policyNames(pol, cfg.HostName) { return anchorTake{}, nil } why = anchorWhyPolicy } // On a networked machine nobody is in charge of, every pairing is refused // every op (unanchoredNetworkedRefusal), so the others that takeAnchor // leaves waiting lose nothing they had; they now have a Host to admit them. // On a local machine the other Hosts are served, so they are not demoted. local := boxMode() != modeNetworked for _, o := range all { if o.HostName == cfg.HostName { continue } if why != anchorWhyPolicy && sameMachineHost(o) && o.invalid == "" && o.State != pairingRevoked { return anchorTake{}, nil } if local && o.active() { return anchorTake{}, nil } } if local { if err := setBoxMode(modeNetworked); err != nil { return anchorTake{}, fmt.Errorf("paired with %s, but making this machine one a Host is in charge of failed: %w", cfg.HostName, err) } pairingAudit("set_mode", cfg.HostName, "ok: networked, so the Host paired through the window opened here can be put in charge") } t, err := takeAnchor(cfg, nil, why, pol, "") if err != nil { return anchorTake{}, fmt.Errorf("paired with %s, but putting it in charge of this machine failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint()) } return t, nil } // anchorReplaced is /anchor.replaced.json: the last time a Host // took the anchor from another. The replaced anchor's run reads it on its // last beat (anchorReplacedNote); the audit keeps the history. type anchorReplaced struct { Host string `json:"host"` // the anchor that was replaced By string `json:"by"` At string `json:"at"` Why string `json:"why"` } func anchorReplacedPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "anchor.replaced.json"), nil } // anchorReplacedNote is what the replaced anchor's heartbeat carries, or nil // when self was not replaced as this machine's anchor. func anchorReplacedNote(self string) map[string]any { p, err := anchorReplacedPath() if err != nil { return nil } raw, err := os.ReadFile(p) if err != nil { return nil } var r anchorReplaced if json.Unmarshal(raw, &r) != nil || r.Host == "" || r.Host != self { return nil } a := currentAnchor() if a == nil || a.AnchorHost != r.By { return nil } return map[string]any{"by": r.By, "at": r.At, "why": r.Why, "waiting_for": r.By} } // clearAnchorReplacedFor removes the replaced-anchor record once that Host is // admitted again. func clearAnchorReplacedFor(host string) { p, err := anchorReplacedPath() if err != nil { return } raw, err := os.ReadFile(p) if err != nil { return } var r anchorReplaced if json.Unmarshal(raw, &r) == nil && r.Host == host { _ = os.Remove(p) } } // takeAnchor makes cfg this machine's anchor, under the box key as // claim-anchor does (writeAnchor), and every other pairing that was served an // ordinary one waiting for it. A previous anchor is recorded as replaced. // owner "" keeps the machine's owner name. func takeAnchor(cfg *Config, prev *anchorClaim, why string, pol *anchorPolicy, owner string) (anchorTake, error) { if cfg.invalid != "" { return anchorTake{}, fmt.Errorf("%s cannot hold the anchor: %s", cfg.HostName, cfg.invalid) } if cfg.State == pairingRevoked { return anchorTake{}, fmt.Errorf("%s is REVOKED on this box — it will not be put in charge; pair that Host again first (`privatae-bastion pair --replace `)", cfg.HostName) } owner = strings.TrimSpace(owner) if owner == "" { if oc := currentOwnerClaim(); oc != nil && oc.Owner != "" { owner = oc.Owner } else if prev != nil { owner = prev.Owner } else { owner = handleOf(cfg.HostName) } } if err := claimNameOK(owner); err != nil { return anchorTake{}, fmt.Errorf("owner: %w", err) } // The anchor never waits on itself. if !cfg.active() { if err := cfg.updateOnDisk(func(d *Config) { d.State = pairingActive }); err != nil { return anchorTake{}, err } } if err := writeAnchor(owner, cfg.HostName); err != nil { return anchorTake{}, err } if _, err := writeOwnerClaim(owner); err != nil { return anchorTake{}, err } // A consent the old anchor left for a rotation is not this. if p, err := anchorConsentPath(); err == nil { _ = os.Remove(p) } t := anchorTake{Taken: true, Why: anchorWhyWords(why, cfg, pol)} if prev != nil && prev.AnchorHost != cfg.HostName { t.Replaced = prev.AnchorHost } // Every other pairing that was served now waits for the new anchor: a // grant from the old one does not verify under the new one's key, so // "active" would be lanes that refuse every op. cfgs, err := loadPairings() if err != nil { return t, err } for _, c := range cfgs { if c.HostName == cfg.HostName || !c.active() { continue } if err := c.updateOnDisk(func(d *Config) { d.State = pairingPending }); err != nil { return t, err } t.Waiting = append(t.Waiting, c.HostName) } at := time.Now().UTC().Format(time.RFC3339) if t.Replaced != "" { if p, err := anchorReplacedPath(); err == nil { if b, err := json.MarshalIndent(anchorReplaced{Host: t.Replaced, By: cfg.HostName, At: at, Why: why}, "", " "); err == nil { _ = atomicWrite(p, b) } } } decision := "ok: in charge of this machine because " + t.Why if t.Replaced != "" { decision += "; it replaced " + t.Replaced } pairingAudit("anchor_take", cfg.HostName, decision) for _, h := range t.Waiting { pairingAudit("anchor_take", h, "waits for "+cfg.HostName+" to admit it") } _ = maybeWriteMarker() kickBeats() return t, nil } // claimAnchorByRule is claim-anchor's part of the rules in the header, before // its own paths run. handled: the command's answer is err. Not handled: the // rest of claim-anchor decides, as before. func claimAnchorByRule(cfgs []*Config, prev *anchorClaim, token string, rotate bool, owner, host string) (bool, error) { named := strings.TrimSpace(host) if token != "" { h, _, err := decodeToken(token) if err != nil { return true, err } named = h } if pol := currentAnchorPolicy(); pol != nil { want := pairingFor(cfgs, pol.Host) if want == nil { for _, c := range cfgs { if policyNames(pol, c.HostName) { want = c break } } } if named != "" && !policyNames(pol, named) { if c, err := pickPairing(cfgs, named); err != nil || want == nil || c.HostName != want.HostName { return true, orgManagedRefusal(pol, named) } } if token != "" { return false, nil // the token names the Host the policy names } if want == nil { return true, fmt.Errorf("an organisation manages this machine: its policy (%s) names %s as the Host in charge "+ "of it, and %s is not paired with this machine yet. Pair it (`privatae-bastion pair `) and it takes charge", pol.Source, pol.Host, pol.Host) } if prev != nil && prev.AnchorHost == want.HostName && want.active() { fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner) return true, nil } t, err := takeAnchor(want, prev, anchorWhyPolicy, pol, owner) if err != nil { return true, err } printAnchorTake(want.HostName, t) return true, nil } if rotate { return false, nil } var locals []*Config for _, c := range cfgs { if sameMachineHost(c) && c.invalid == "" && c.State != pairingRevoked { locals = append(locals, c) } } if len(locals) == 0 { return false, nil } var want *Config switch { case token != "": if pairingFor(locals, named) == nil { return true, fmt.Errorf("%s runs on this machine and is the Host in charge of it, so %s does not take the "+ "anchor here; %s admits it instead (it asks you to approve it)", locals[0].HostName, named, locals[0].HostName) } return false, nil case named != "": c, err := pickPairing(cfgs, named) if err != nil { return true, err } if !sameMachineHost(c) { return true, fmt.Errorf("%s runs on this machine and is the Host in charge of it, so %s does not take the "+ "anchor here; %s admits it instead (it asks you to approve it)", locals[0].HostName, c.HostName, locals[0].HostName) } want = c case len(locals) == 1: want = locals[0] default: return false, nil // several Hosts on this machine: --host says which } if prev != nil && prev.AnchorHost == want.HostName && want.active() { fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner) return true, nil } if prev != nil { if pc := pairingFor(cfgs, prev.AnchorHost); pc != nil && sameMachineHost(pc) && pc.active() { return false, nil // moving it between two Hosts on this machine is --rotate, as before } } t, err := takeAnchor(want, prev, anchorWhyLocal, nil, owner) if err != nil { return true, err } printAnchorTake(want.HostName, t) return true, nil } // printAnchorTake is claim-anchor's report of takeAnchor. func printAnchorTake(host string, t anchorTake) { fmt.Printf("anchored to %s: %s.\n", host, t.Why) if t.Replaced != "" { fmt.Printf(" %s was in charge before; it is told so, and now waits for %s to admit it.\n", t.Replaced, host) } if len(t.Waiting) > 0 { fmt.Printf(" Waiting for %s to admit them: %s. %s asks you to approve each.\n", host, strings.Join(t.Waiting, ", "), host) } fmt.Println(" Every Host paired from now on waits until " + host + " admits it.") fmt.Println(" " + anchorFollowNote()) }