bastion 0.63.1
This commit is contained in:
+98
-10
@@ -799,13 +799,46 @@ func lastOK(host string) time.Time {
|
||||
|
||||
// pairingEntry is one pairing on this box as told to the ANCHOR host: name,
|
||||
// handle, state, since. Same privacy line as peers.go — never keys or grants.
|
||||
//
|
||||
// For a pairing that waits to be admitted it also says what the anchor Host
|
||||
// needs to raise the approval prompt for the person (C-Series: the Network
|
||||
// app's pending pairing, approved with approve_pairing):
|
||||
//
|
||||
// waiting_for the Host that admits it: the anchor, which reads its own
|
||||
// name here (anchor_local.go admitter)
|
||||
// transport how its lanes would run: "tunnel" (WireGuard through the
|
||||
// relay, a Cloud Host), "tcp" (a Host's lane port) or "unix"
|
||||
// (a socket on this machine)
|
||||
// same_machine its Host runs on this machine (anchor_local.go
|
||||
// sameMachineHost)
|
||||
// origin how it came: "paired" (a code redeemed at this machine),
|
||||
// "carried" (moved in from an account's own bastion when the
|
||||
// machine moved to the system service, servicecarry.go), or
|
||||
// "former_anchor" (it was in charge of this machine until a
|
||||
// Host the rules put first took over, anchor_local.go)
|
||||
//
|
||||
// The approval itself is unchanged: pairing_grant_sync with terms the anchor
|
||||
// signs, {kind: pairing_grant, server: the beat's hostname, anchor: this
|
||||
// beat's anchor.anchor_host, subject: host_name, subject_handle: handle, caps,
|
||||
// issued_at, expires}, over the anchor's own mgmt lane (pairing_ops.go).
|
||||
type pairingEntry struct {
|
||||
HostName string `json:"host_name"`
|
||||
Handle string `json:"handle"`
|
||||
State string `json:"state"`
|
||||
Since string `json:"since,omitempty"`
|
||||
HostName string `json:"host_name"`
|
||||
Handle string `json:"handle"`
|
||||
State string `json:"state"`
|
||||
Since string `json:"since,omitempty"`
|
||||
WaitingFor string `json:"waiting_for,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
SameMachine bool `json:"same_machine,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
// The origins pairingEntry reports.
|
||||
const (
|
||||
pairingOriginPaired = "paired"
|
||||
pairingOriginCarried = "carried"
|
||||
pairingOriginFormerAnchor = "former_anchor"
|
||||
)
|
||||
|
||||
// pairingsReport lists EVERY pairing on the box, including the one asking.
|
||||
// "since" is first_seen — when the host was first paired here — kept across
|
||||
// --replace, so a pending host re-presenting a token keeps its original age
|
||||
@@ -821,13 +854,41 @@ func pairingsReport() []pairingEntry {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
who := admitter()
|
||||
replaced := ""
|
||||
if p, err := anchorReplacedPath(); err == nil {
|
||||
if raw, err := os.ReadFile(p); err == nil {
|
||||
var r anchorReplaced
|
||||
if json.Unmarshal(raw, &r) == nil {
|
||||
replaced = r.Host
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]pairingEntry, 0, len(cfgs))
|
||||
for _, c := range cfgs {
|
||||
if c.HostName == "" || c.invalid != "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName),
|
||||
State: c.stateName(), Since: sinceOf(c)})
|
||||
e := pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName),
|
||||
State: c.stateName(), Since: sinceOf(c)}
|
||||
if c.State == pairingPending {
|
||||
e.WaitingFor = who
|
||||
e.Transport = "tunnel"
|
||||
if c.unixTransport() {
|
||||
e.Transport = "unix"
|
||||
} else if c.tcpTransport() {
|
||||
e.Transport = "tcp"
|
||||
}
|
||||
e.SameMachine = sameMachineHost(c)
|
||||
e.Origin = pairingOriginPaired
|
||||
if c.Origin == pairingOriginCarried {
|
||||
e.Origin = pairingOriginCarried
|
||||
}
|
||||
if replaced != "" && replaced == c.HostName {
|
||||
e.Origin = pairingOriginFormerAnchor
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -855,7 +916,7 @@ func unapprovedAttempts(now time.Time) []peerAnnounce {
|
||||
if err != nil || now.Sub(since) < unapprovedAfter {
|
||||
continue
|
||||
}
|
||||
out = append(out, peerAnnounce(e))
|
||||
out = append(out, peerAnnounce{HostName: e.HostName, Handle: e.Handle, State: e.State, Since: e.Since})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -874,6 +935,16 @@ func pairingEnforcing(self string) map[string]any {
|
||||
return out
|
||||
}
|
||||
out["pairing_state"] = c.stateName()
|
||||
// A Host the rules put first took the anchor from this one (anchor_local.go):
|
||||
// {by, at, why, waiting_for}. Its last beat before its lanes stop carries it.
|
||||
if note := anchorReplacedNote(self); note != nil {
|
||||
out["anchor_replaced"] = note
|
||||
}
|
||||
if c.State == pairingPending {
|
||||
if who := admitter(); who != "" {
|
||||
out["waiting_for"] = who
|
||||
}
|
||||
}
|
||||
// The name THIS pairing is known by on this box — what a reach grant must
|
||||
// name as its subject (service_reach.go). A host can hold more than one name
|
||||
// (a legacy one and the current one); this says which one the box enforces.
|
||||
@@ -1001,7 +1072,7 @@ func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool {
|
||||
// pickPairing resolves --host against this box's pairings. It accepts the full
|
||||
// host_name the box reports, or just the handle in front of it, because the
|
||||
// name the user is looking at depends on which surface they read it from —
|
||||
// `8abb770bbfde71d0` in Circles, `8abb770bbfde71d0.host.privatae.ai` in the
|
||||
// `1b2c3d4e5f607182` in Circles, `1b2c3d4e5f607182.host.privatae.ai` in the
|
||||
// claim-anchor error. Refusing the short form would mean refusing the thing the
|
||||
// user most obviously typed.
|
||||
func pickPairing(cfgs []*Config, want string) (*Config, error) {
|
||||
@@ -1041,6 +1112,12 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
|
||||
return err
|
||||
}
|
||||
prev := currentAnchor()
|
||||
// Which Host is in charge (anchor_local.go): the one an organisation's
|
||||
// policy names, else a Host on this machine. Either decides here, before
|
||||
// the paths below, which know only "the box's pairings".
|
||||
if handled, err := claimAnchorByRule(cfgs, prev, token, rotate, owner, host); handled {
|
||||
return err
|
||||
}
|
||||
// Already anchored to the host this asks for (no token, no other host
|
||||
// named), and that host's pairing is active: done, not an error. `pair`
|
||||
// anchors a networked box to its first host by itself, and an installer
|
||||
@@ -1236,11 +1313,22 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
|
||||
// singleton on each one as it goes, rather than needing a separate pass.
|
||||
_ = maybeWriteMarker()
|
||||
fmt.Printf("anchored to %s (owner %q)\n", cfg.HostName, owner)
|
||||
fmt.Println(" Every host paired from now on waits, pending, until this anchor host approves it.")
|
||||
fmt.Println(" " + restartHint())
|
||||
fmt.Printf(" Every Host paired from now on waits until %s admits it: %s asks you to approve it.\n", cfg.HostName, cfg.HostName)
|
||||
fmt.Println(" " + anchorFollowNote())
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorFollowNote says how the change reaches the bastion: a running one
|
||||
// reads the anchor on every op and follows the pairing files within
|
||||
// supervisePeriod, so nothing is restarted.
|
||||
func anchorFollowNote() string {
|
||||
if pairDaemonRunning() {
|
||||
return fmt.Sprintf("The bastion running on this machine follows within %d seconds; nothing needs restarting.",
|
||||
int(supervisePeriod.Seconds()))
|
||||
}
|
||||
return "Start the bastion so it serves it: " + runCommandHint()
|
||||
}
|
||||
|
||||
// writeAnchor signs and installs anchor.json under the box key. A fresh anchor
|
||||
// (or a rotated one) starts with NO verify key: the new anchor host's key is
|
||||
// pinned on first contact, never carried over from the previous host.
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
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 <configDir>/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 <token>`)", 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 <its code>`) 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())
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tLocal = "home.cseries.localhost"
|
||||
tLocal2 = "second.cseries.localhost"
|
||||
tRemote = "remote.host.privatae.ai"
|
||||
)
|
||||
|
||||
// localBox is a networked box with machine authority in a temp config dir.
|
||||
func localBox(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
||||
prev := machineAuthorityHeld
|
||||
machineAuthorityHeld = func() bool { return true }
|
||||
t.Cleanup(func() { machineAuthorityHeld = prev })
|
||||
withPolicy(t, nil)
|
||||
if err := setBoxMode(modeNetworked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func withPolicy(t *testing.T, p *anchorPolicy) {
|
||||
t.Helper()
|
||||
prev := readAnchorPolicy
|
||||
readAnchorPolicy = func() *anchorPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
c := *p
|
||||
return &c
|
||||
}
|
||||
t.Cleanup(func() { readAnchorPolicy = prev })
|
||||
}
|
||||
|
||||
// writeTCPPairing is a Host reached on a lane port, as pair_tcp.go writes it.
|
||||
func writeTCPPairing(t *testing.T, host, lane, state string) {
|
||||
t.Helper()
|
||||
pd, _ := pairingsDir()
|
||||
// Its control port is one nothing listens on, whatever runs on the machine
|
||||
// running the suite (a bastion of its own answers on 7878).
|
||||
b, _ := json.Marshal(Config{HostName: host, Transport: transportTCP, LaneAddr: lane, WGPrivateKey: "k", State: state,
|
||||
ListenAddr: "127.0.0.1:1"})
|
||||
if err := os.WriteFile(filepath.Join(pd, host+".json"), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func pairingNamed(t *testing.T, host string) (*Config, []*Config) {
|
||||
t.Helper()
|
||||
all, err := loadPairings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := pairingFor(all, host)
|
||||
if c == nil {
|
||||
t.Fatalf("%s not paired", host)
|
||||
}
|
||||
return c, all
|
||||
}
|
||||
|
||||
func auditLines(t *testing.T) string {
|
||||
t.Helper()
|
||||
d, _ := configDir()
|
||||
b, _ := os.ReadFile(filepath.Join(d, "pairing_audit.jsonl"))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// What "on this machine" is: a unix socket, or a lane port on loopback. A LAN
|
||||
// address, the relay's tunnel, and a Host's own name are not.
|
||||
//
|
||||
// MUTATION: return true for every tcp pairing in sameMachineHost. RESULT: red.
|
||||
func TestSameMachineIsASocketOrALoopbackLane(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
cfg Config
|
||||
want bool
|
||||
}{
|
||||
{Config{Transport: transportUnix, LaneSocket: "/run/h/lane.sock"}, true},
|
||||
{Config{Transport: transportTCP, LaneAddr: "127.0.0.1:8811"}, true},
|
||||
{Config{Transport: transportTCP, LaneAddr: "127.3.4.5:8811"}, true},
|
||||
{Config{Transport: transportTCP, LaneAddr: "[::1]:8811"}, true},
|
||||
{Config{Transport: transportTCP, LaneAddr: "localhost:8811"}, true},
|
||||
{Config{Transport: transportTCP, LaneAddr: "10.0.2.2:28811"}, false},
|
||||
{Config{Transport: transportTCP, LaneAddr: "192.168.1.20:8811"}, false},
|
||||
{Config{Transport: transportTCP, LaneAddr: "home.cseries.localhost:8811"}, false},
|
||||
{Config{HostName: "x.cseries.localhost", BastionTunnelIP: "fd12:3456::a:1"}, false},
|
||||
} {
|
||||
if got := sameMachineHost(&c.cfg); got != c.want {
|
||||
t.Errorf("%+v: same machine = %v, want %v", c.cfg, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The S0 order the other way round: the machine was anchored to a remote Host,
|
||||
// then a Host on this machine is paired with machine authority. It takes the
|
||||
// anchor; the remote one becomes an ordinary pairing waiting for it, is told
|
||||
// on its beat, and the audit says so.
|
||||
//
|
||||
// MUTATION: drop the sameMachineHost case from anchorAfterPair. RESULT: red.
|
||||
// Leave the replaced anchor active in takeAnchor. RESULT: red.
|
||||
func TestAHostOnThisMachineTakesTheAnchorFromARemoteOne(t *testing.T) {
|
||||
localBox(t)
|
||||
writePairing(t, tRemote)
|
||||
if err := writeAnchor("the owner", tRemote); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
|
||||
cfg, all := pairingNamed(t, tLocal)
|
||||
took, err := anchorAfterPair(cfg, all)
|
||||
if err != nil || !took.Taken || took.Replaced != tRemote {
|
||||
t.Fatalf("a Host on this machine must take the anchor: %+v %v", took, err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tLocal || a.Owner != "the owner" {
|
||||
t.Fatalf("anchor = %+v", a)
|
||||
}
|
||||
if got := loadState(t, tLocal); got != pairingActive {
|
||||
t.Errorf("the new anchor waits on itself: %q", got)
|
||||
}
|
||||
if got := loadState(t, tRemote); got != pairingPending {
|
||||
t.Errorf("the replaced anchor is %q, want pending", got)
|
||||
}
|
||||
a := auditLines(t)
|
||||
if !strings.Contains(a, `"op":"anchor_take"`) || !strings.Contains(a, "it replaced "+tRemote) ||
|
||||
!strings.Contains(a, "waits for "+tLocal+" to admit it") {
|
||||
t.Errorf("the audit does not record it:\n%s", a)
|
||||
}
|
||||
note := anchorReplacedNote(tRemote)
|
||||
if note == nil || note["by"] != tLocal || note["why"] != anchorWhyLocal {
|
||||
t.Fatalf("the replaced anchor's beat is not told: %v", note)
|
||||
}
|
||||
if e := pairingEnforcing(tRemote); e["anchor_replaced"] == nil || e["waiting_for"] != tLocal {
|
||||
t.Errorf("the enforcing report of the replaced anchor: %v", e)
|
||||
}
|
||||
if anchorReplacedNote(tLocal) != nil {
|
||||
t.Error("the new anchor is told it was replaced")
|
||||
}
|
||||
var former *pairingEntry
|
||||
for _, e := range pairingsReport() {
|
||||
if e.HostName == tRemote {
|
||||
e := e
|
||||
former = &e
|
||||
}
|
||||
}
|
||||
if former == nil || former.Origin != pairingOriginFormerAnchor || former.WaitingFor != tLocal || former.Transport != "tunnel" || former.SameMachine {
|
||||
t.Errorf("the anchor's report of the former anchor: %+v", former)
|
||||
}
|
||||
}
|
||||
|
||||
// A networked box with no anchor and a remote Host already serving: the Host on
|
||||
// this machine takes the anchor, and the remote one waits for it.
|
||||
func TestAHostOnThisMachineAnchorsABoxThatAlreadyServesARemoteHost(t *testing.T) {
|
||||
localBox(t)
|
||||
writePairing(t, tRemote)
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
cfg, all := pairingNamed(t, tLocal)
|
||||
took, err := anchorAfterPair(cfg, all)
|
||||
if err != nil || !took.Taken || took.Replaced != "" || strings.Join(took.Waiting, ",") != tRemote {
|
||||
t.Fatalf("%+v %v", took, err)
|
||||
}
|
||||
if got := loadState(t, tRemote); got != pairingPending {
|
||||
t.Errorf("the remote Host holds no grant from the new anchor and must wait: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Never "whichever paired first": a remote Host never takes the anchor from a
|
||||
// Host on this machine, and a second Host on this machine does not either.
|
||||
//
|
||||
// MUTATION: drop the "a Host on this machine already holds it" return.
|
||||
// RESULT: red.
|
||||
func TestTheAnchorOnThisMachineIsNotReplaced(t *testing.T) {
|
||||
for _, newcomer := range []struct{ host, lane string }{{tRemote, "10.0.2.2:28811"}, {tLocal2, "127.0.0.1:9811"}} {
|
||||
t.Run(newcomer.host, func(t *testing.T) {
|
||||
localBox(t)
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
if err := writeAnchor("the owner", tLocal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTCPPairing(t, newcomer.host, newcomer.lane, pairingPending)
|
||||
cfg, all := pairingNamed(t, newcomer.host)
|
||||
if took, err := anchorAfterPair(cfg, all); took.Taken || err != nil {
|
||||
t.Fatalf("%s took the anchor: %+v %v", newcomer.host, took, err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tLocal {
|
||||
t.Fatalf("anchor moved: %+v", a)
|
||||
}
|
||||
if got := loadState(t, newcomer.host); got != pairingPending {
|
||||
t.Errorf("state %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Without machine authority, or on a local machine, nothing is anchored.
|
||||
func TestTakingTheAnchorNeedsANetworkedMachineAndItsAuthority(t *testing.T) {
|
||||
localBox(t)
|
||||
writePairing(t, tRemote)
|
||||
if err := writeAnchor("the owner", tRemote); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
|
||||
machineAuthorityHeld = func() bool { return false }
|
||||
cfg, all := pairingNamed(t, tLocal)
|
||||
if took, _ := anchorAfterPair(cfg, all); took.Taken {
|
||||
t.Fatal("anchored without machine authority")
|
||||
}
|
||||
machineAuthorityHeld = func() bool { return true }
|
||||
localBox(t)
|
||||
if err := setBoxMode(modeLocal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
cfg, all = pairingNamed(t, tLocal)
|
||||
if took, _ := anchorAfterPair(cfg, all); took.Taken || currentAnchor() != nil {
|
||||
t.Fatal("anchored a local machine")
|
||||
}
|
||||
}
|
||||
|
||||
// An organisation's policy wins over the Host on this machine: the Host it
|
||||
// names takes the anchor, from a Host on this machine too; any other Host
|
||||
// waits for it; and claim-anchor for any other Host is refused in words that
|
||||
// say an organisation manages the machine.
|
||||
//
|
||||
// MUTATION: check sameMachineHost before the policy in anchorAfterPair.
|
||||
// RESULT: red. Drop the policy refusal in claimAnchorByRule. RESULT: red.
|
||||
func TestAnOrganisationsPolicyNamesTheHostInCharge(t *testing.T) {
|
||||
localBox(t)
|
||||
withPolicy(t, &anchorPolicy{Host: tRemote, Source: "HKLM\\SOFTWARE\\Policies\\Privatae\\Bastion AnchorHost"})
|
||||
|
||||
// Unanchored: the Host on this machine waits for the one the policy names.
|
||||
if _, state, err := pairPrecheckState(false, handleOf(tLocal)); err != nil || state != pairingPending {
|
||||
t.Fatalf("a Host the policy does not name must wait: %q %v", state, err)
|
||||
}
|
||||
if _, state, _ := pairPrecheckState(false, handleOf(tRemote)); state != "" {
|
||||
t.Fatalf("the Host the policy names waits for nobody: %q", state)
|
||||
}
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
|
||||
cfg, all := pairingNamed(t, tLocal)
|
||||
if took, _ := anchorAfterPair(cfg, all); took.Taken {
|
||||
t.Fatal("the Host on this machine took the anchor against the organisation's policy")
|
||||
}
|
||||
if got := admitter(); got != tRemote {
|
||||
t.Errorf("a pending pairing waits for %q, want the policy's Host", got)
|
||||
}
|
||||
err := cmdClaimAnchor("", false, "", tLocal)
|
||||
if err == nil || !strings.Contains(err.Error(), "an organisation manages this machine") ||
|
||||
!strings.Contains(err.Error(), "names "+tRemote) {
|
||||
t.Fatalf("claim-anchor for another Host: %v", err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", ""); err == nil || !strings.Contains(err.Error(), "not paired with this machine yet") {
|
||||
t.Fatalf("claim-anchor before the policy's Host is paired: %v", err)
|
||||
}
|
||||
|
||||
// The policy's Host is paired: it takes the anchor, the other one waits.
|
||||
writePairingState(t, tRemote, "")
|
||||
cfg, all = pairingNamed(t, tRemote)
|
||||
took, err := anchorAfterPair(cfg, all)
|
||||
if err != nil || !took.Taken {
|
||||
t.Fatalf("the policy's Host must take the anchor: %+v %v", took, err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tRemote {
|
||||
t.Fatalf("anchor = %+v", a)
|
||||
}
|
||||
if !strings.Contains(auditLines(t), "an organisation's policy") {
|
||||
t.Errorf("the audit does not name the policy: %s", auditLines(t))
|
||||
}
|
||||
if err := cmdClaimAnchor("", true, "", tLocal); err == nil || !strings.Contains(err.Error(), "an organisation manages this machine") {
|
||||
t.Fatalf("claim-anchor --rotate to another Host: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A policy deployed after a Host on this machine took the anchor: claim-anchor
|
||||
// with no argument hands it to the Host the policy names, without --rotate.
|
||||
func TestClaimAnchorFollowsAPolicyDeployedLater(t *testing.T) {
|
||||
localBox(t)
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
if err := writeAnchor("the owner", tLocal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePairingState(t, tRemote, pairingPending)
|
||||
withPolicy(t, &anchorPolicy{Host: handleOf(tRemote), Source: "/etc/privatae-bastion/policy.json anchor_host"})
|
||||
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tRemote {
|
||||
t.Fatalf("anchor = %+v", a)
|
||||
}
|
||||
if got := loadState(t, tLocal); got != pairingPending {
|
||||
t.Errorf("the Host on this machine is %q, want waiting for the policy's Host", got)
|
||||
}
|
||||
}
|
||||
|
||||
// claim-anchor with no argument on a box that serves a remote Host and a Host
|
||||
// on this machine picks the one on this machine; naming the remote one is
|
||||
// refused while a Host on this machine is paired.
|
||||
//
|
||||
// MUTATION: return false at the top of claimAnchorByRule's same-machine part.
|
||||
// RESULT: red (the old "say which one" refusal).
|
||||
func TestClaimAnchorPicksTheHostOnThisMachine(t *testing.T) {
|
||||
localBox(t)
|
||||
writePairing(t, tRemote)
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
if err := cmdClaimAnchor("", false, "", tRemote); err == nil || !strings.Contains(err.Error(), "runs on this machine and is the Host in charge") {
|
||||
t.Fatalf("naming the remote Host: %v", err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tLocal {
|
||||
t.Fatalf("anchor = %+v", a)
|
||||
}
|
||||
if got := loadState(t, tRemote); got != pairingPending {
|
||||
t.Errorf("the remote Host is %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The replaced anchor's running beat is beaten once more before its lanes stop.
|
||||
//
|
||||
// MUTATION: drop the tellReplacedAnchor call in superviseOnce, or the
|
||||
// tellReplacedAnchorsBeforeStop body. RESULT: red.
|
||||
func TestTheReplacedAnchorIsToldOnALastBeat(t *testing.T) {
|
||||
localBox(t)
|
||||
writePairing(t, tRemote)
|
||||
if err := writeAnchor("the owner", tRemote); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
|
||||
remote, _ := pairingNamed(t, tRemote)
|
||||
cfg, all := pairingNamed(t, tLocal)
|
||||
if took, err := anchorAfterPair(cfg, all); err != nil || !took.Taken {
|
||||
t.Fatal(took, err)
|
||||
}
|
||||
var told any
|
||||
beat := func() { told = pairingEnforcing(tRemote)["anchor_replaced"] }
|
||||
_, unregister := registerRunBeat(tRemote, beat)
|
||||
defer unregister()
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
supervisor.Lock()
|
||||
supervisor.runs = map[string]*pairingRun{tRemote: {cancel: func() {}, done: done, cfg: remote}}
|
||||
supervisor.Unlock()
|
||||
t.Cleanup(func() { supervisor.Lock(); supervisor.runs = nil; supervisor.Unlock() })
|
||||
// The daemon stopping first (an installer's Restart-Service right after
|
||||
// `pair`) tells it as well.
|
||||
tellReplacedAnchorsBeforeStop()
|
||||
if told == nil {
|
||||
t.Fatal("the daemon stopped without a last beat to the replaced anchor")
|
||||
}
|
||||
told = nil
|
||||
superviseOnce()
|
||||
if told == nil {
|
||||
t.Fatal("the replaced anchor was stopped without a last beat that says so")
|
||||
}
|
||||
// Admitted again, it is no longer told.
|
||||
clearAnchorReplacedFor(tRemote)
|
||||
if anchorReplacedNote(tRemote) != nil {
|
||||
t.Error("an admitted former anchor still hears it was replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// One pairing's fatal error stops that pairing alone: nothing reaches the
|
||||
// daemon's fatal channel, the failure is recorded for status, and it is tried
|
||||
// again after a pause.
|
||||
//
|
||||
// MUTATION: send the error to supervisor.errc again. RESULT: red.
|
||||
func TestOnePairingsFatalErrorStopsOnlyThatPairing(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
freshLocalEvents(t)
|
||||
// A Host over TCP whose CA does not read: runUnixPairing fails at once.
|
||||
bad := &Config{HostName: "broken.example.test", Transport: transportTCP, LaneAddr: "127.0.0.1:1", Bearer: "b",
|
||||
CACert: "not a certificate", ListenAddr: "127.0.0.1:0"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errc := make(chan error, 4)
|
||||
supervisor.Lock()
|
||||
prevCtx, prevErrc, prevRuns := supervisor.ctx, supervisor.errc, supervisor.runs
|
||||
supervisor.ctx, supervisor.errc, supervisor.runs = ctx, errc, nil
|
||||
supervisor.Unlock()
|
||||
t.Cleanup(func() {
|
||||
supervisor.Lock()
|
||||
supervisor.ctx, supervisor.errc, supervisor.runs = prevCtx, prevErrc, prevRuns
|
||||
supervisor.Unlock()
|
||||
pairingRecovered(bad.HostName)
|
||||
})
|
||||
if !startPairing(bad) {
|
||||
t.Fatal("not started")
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, ok := loadPairingFailures()[bad.HostName]; ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case err := <-errc:
|
||||
t.Fatalf("one pairing's error went to the whole daemon: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
f, ok := loadPairingFailures()[bad.HostName]
|
||||
if !ok || f.Error == "" || f.Tries != 1 {
|
||||
t.Fatalf("the failure is not recorded: %+v", f)
|
||||
}
|
||||
if pairingRetryDue(bad.HostName) {
|
||||
t.Error("tried again with no pause")
|
||||
}
|
||||
if retryPause(1) != supervisePeriod || retryPause(3) != 4*supervisePeriod || retryPause(40) != retryMaxPause {
|
||||
t.Errorf("pauses %v %v %v", retryPause(1), retryPause(3), retryPause(40))
|
||||
}
|
||||
found := false
|
||||
for _, e := range localEventsNewestFirst() {
|
||||
found = found || strings.Contains(e.Text, "The other Hosts go on being served")
|
||||
}
|
||||
if !found {
|
||||
t.Error("the machine's events do not say so")
|
||||
}
|
||||
}
|
||||
|
||||
// The Windows service manager's stop is logged as its own, not the tray's.
|
||||
// The service handler cannot run here, so its source is read.
|
||||
//
|
||||
// MUTATION: call requestShutdown() in sysservice_windows.go. RESULT: red.
|
||||
func TestTheServiceManagersStopIsNotLoggedAsTheTrays(t *testing.T) {
|
||||
src, err := os.ReadFile("sysservice_windows.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), "requestShutdownFor(shutdownFromService)") || strings.Contains(string(src), "requestShutdown()") {
|
||||
t.Error("the service's stop does not name the service manager")
|
||||
}
|
||||
run, _ := os.ReadFile("run.go")
|
||||
if !strings.Contains(string(run), `"\nstopping — " + shutdownWhy`) {
|
||||
t.Error("cmdRun does not log who asked it to stop")
|
||||
}
|
||||
if strings.Contains(shutdownFromService, "tray") {
|
||||
t.Error(shutdownFromService)
|
||||
}
|
||||
}
|
||||
|
||||
// Over a network logon (ssh) the service cannot be queried for its process id;
|
||||
// the ask pipe is then checked by its owner, SYSTEM, instead of refused.
|
||||
//
|
||||
// MUTATION: drop the ERROR_ACCESS_DENIED branch. RESULT: red.
|
||||
func TestTheAskPipeIsCheckedByItsOwnerWhenTheServiceCannotBeQueried(t *testing.T) {
|
||||
src, err := os.ReadFile("approvalpipe_windows.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
i := strings.Index(s, "func pipeServerIsService(")
|
||||
if i < 0 {
|
||||
t.Fatal("no pipeServerIsService")
|
||||
}
|
||||
body := s[i : i+strings.Index(s[i:], "\n}\n")]
|
||||
if !strings.Contains(body, "errors.Is(err, windows.ERROR_ACCESS_DENIED)") || !strings.Contains(body, "return pipeOwnedBySystem(h)") {
|
||||
t.Error("a network logon is still refused")
|
||||
}
|
||||
if !strings.Contains(s, "!owner.IsWellKnown(windows.WinLocalSystemSid) && !owner.IsWellKnown(windows.WinBuiltinAdministratorsSid)") {
|
||||
t.Error("the owner check does not require LocalSystem or the Administrators group")
|
||||
}
|
||||
}
|
||||
|
||||
// The last beat to a replaced anchor (lastBeat, from superviseOnce's
|
||||
// goroutine) never runs beside the run's own beat or path failover: both write
|
||||
// cfg.Endpoints and the WG device, and before lastBeat a run's beats all came
|
||||
// from its one loop.
|
||||
func TestTheLastBeatNeverRunsBesideTheRunsOwnBeat(t *testing.T) {
|
||||
const host = "serial-beat.host.privatae.ai"
|
||||
var mu sync.Mutex
|
||||
in, most := 0, 0
|
||||
entered := make(chan struct{}, 4)
|
||||
release := make(chan struct{})
|
||||
beat := func() {
|
||||
mu.Lock()
|
||||
in++
|
||||
if in > most {
|
||||
most = in
|
||||
}
|
||||
mu.Unlock()
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
mu.Lock()
|
||||
in--
|
||||
mu.Unlock()
|
||||
}
|
||||
rb, unregister := registerRunBeat(host, beat)
|
||||
defer unregister()
|
||||
old := lastBeatWait
|
||||
lastBeatWait = 200 * time.Millisecond
|
||||
t.Cleanup(func() { lastBeatWait = old })
|
||||
|
||||
loopDone := make(chan struct{})
|
||||
go func() { defer close(loopDone); rb.run() }() // the run's own beat, in flight
|
||||
<-entered
|
||||
failoverRan := make(chan struct{})
|
||||
go func() { rb.do(func() {}); close(failoverRan) }() // the tick's failover waits too
|
||||
if !lastBeat(host) {
|
||||
t.Fatal("lastBeat found no beat for a running pairing")
|
||||
}
|
||||
select {
|
||||
case <-entered:
|
||||
t.Fatal("the last beat ran beside the run's own beat")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-failoverRan:
|
||||
t.Fatal("path failover ran beside a beat")
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
<-loopDone
|
||||
<-failoverRan
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if most != 1 {
|
||||
t.Fatalf("%d beats ran at once, want 1", most)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// anchorPolicyPlist is the machine-wide managed preference domain a
|
||||
// configuration profile (MDM) installs for the bastion. The folder is root's
|
||||
// and the system writes it; a file there not owned by root is not a policy.
|
||||
const anchorPolicyPlist = "/Library/Managed Preferences/ai.privatae.bastion.plist"
|
||||
|
||||
func readAnchorPolicyOS() *anchorPolicy {
|
||||
fi, err := os.Lstat(anchorPolicyPlist)
|
||||
if err != nil || !fi.Mode().IsRegular() || fi.Mode().Perm()&0o022 != 0 {
|
||||
return nil
|
||||
}
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 {
|
||||
return nil
|
||||
}
|
||||
// defaults reads the binary and the XML form alike.
|
||||
out, err := exec.Command("/usr/bin/defaults", "read", strings.TrimSuffix(anchorPolicyPlist, ".plist"), "AnchorHost").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(string(out))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &anchorPolicy{Host: v, Source: "the managed preference ai.privatae.bastion AnchorHost"}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The policy readers accept only what the machine's administrator or policy
|
||||
// engine could have written: on Linux a file root owns that nobody else can
|
||||
// write. Run as an ordinary user, the suite's own file is not root's, so it is
|
||||
// no policy.
|
||||
func TestALinuxPolicyFileMustBeRoots(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
prev := anchorPolicyFile
|
||||
anchorPolicyFile = filepath.Join(dir, "policy.json")
|
||||
t.Cleanup(func() { anchorPolicyFile = prev })
|
||||
if err := os.WriteFile(anchorPolicyFile, []byte(`{"anchor_host":"`+"managed.host.privatae.ai"+`"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := readAnchorPolicyOS()
|
||||
if os.Geteuid() == 0 {
|
||||
if p == nil || p.Host != "managed.host.privatae.ai" {
|
||||
t.Fatalf("root's own policy file was not read: %+v", p)
|
||||
}
|
||||
_ = os.Chmod(anchorPolicyFile, 0o666)
|
||||
if readAnchorPolicyOS() != nil {
|
||||
t.Error("a policy file anyone can write was read")
|
||||
}
|
||||
return
|
||||
}
|
||||
if p != nil {
|
||||
t.Errorf("a policy file root does not own was read: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// anchorPolicyFile is the managed configuration an organisation deploys to a
|
||||
// Linux machine (by its configuration management) for the bastion: root's,
|
||||
// and writable by nobody else. A file anyone else could have written is not a
|
||||
// policy.
|
||||
var anchorPolicyFile = "/etc/privatae-bastion/policy.json"
|
||||
|
||||
func readAnchorPolicyOS() *anchorPolicy {
|
||||
fi, err := os.Lstat(anchorPolicyFile)
|
||||
if err != nil || !fi.Mode().IsRegular() || fi.Mode().Perm()&0o022 != 0 {
|
||||
return nil
|
||||
}
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(anchorPolicyFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var p struct {
|
||||
AnchorHost string `json:"anchor_host"`
|
||||
}
|
||||
if json.Unmarshal(raw, &p) != nil || strings.TrimSpace(p.AnchorHost) == "" {
|
||||
return nil
|
||||
}
|
||||
return &anchorPolicy{Host: p.AnchorHost, Source: anchorPolicyFile + " anchor_host"}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// anchorPolicyKey is where Group Policy and Intune deploy a machine policy for
|
||||
// the bastion: under HKLM\SOFTWARE\Policies, which only an administrator or
|
||||
// the policy engine writes. Domain membership is not read.
|
||||
const anchorPolicyKey = `SOFTWARE\Policies\Privatae\Bastion`
|
||||
|
||||
func readAnchorPolicyOS() *anchorPolicy {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, anchorPolicyKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer k.Close()
|
||||
v, _, err := k.GetStringValue("AnchorHost")
|
||||
if err != nil || strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return &anchorPolicy{Host: v, Source: `HKLM\` + anchorPolicyKey + ` AnchorHost`}
|
||||
}
|
||||
@@ -441,6 +441,18 @@ func pipeServerIsService(h windows.Handle) error {
|
||||
return fmt.Errorf("cannot tell which process serves the pipe: %w", err)
|
||||
}
|
||||
svcPID, err := bastionServicePID()
|
||||
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||
// The service's default permissions let an INTERACTIVE logon query it,
|
||||
// not a network one (ssh, a remote PowerShell): the owner's own
|
||||
// `status` over ssh was refused as "Access is denied". The pipe's
|
||||
// owner says what the process id would: servePipe sets no owner, so
|
||||
// the pipe's is the creating token's default owner, which for
|
||||
// LocalSystem is BUILTIN\Administrators (seen on Windows Server 2022)
|
||||
// or SYSTEM, and no program of an ordinary account can make an object
|
||||
// either owns. An administrator's own program could; an administrator
|
||||
// already holds the service itself.
|
||||
return pipeOwnedBySystem(h)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -450,6 +462,23 @@ func pipeServerIsService(h windows.Handle) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pipeOwnedBySystem refuses a pipe whose owner is not LocalSystem or the
|
||||
// Administrators group, the two owners LocalSystem's objects get.
|
||||
func pipeOwnedBySystem(h windows.Handle) error {
|
||||
sd, err := windows.GetSecurityInfo(h, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot tell who made the pipe: %w", err)
|
||||
}
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil || owner == nil {
|
||||
return fmt.Errorf("cannot tell who made the pipe: %v", err)
|
||||
}
|
||||
if !owner.IsWellKnown(windows.WinLocalSystemSid) && !owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) {
|
||||
return fmt.Errorf("the pipe was made by %s, not by the %s service; nothing was sent", owner.String(), winServiceName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bastionServicePID is the service's process id from the Service Control
|
||||
// Manager, asked with the query right every signed-in user holds. 0 when it is
|
||||
// not running.
|
||||
|
||||
@@ -377,6 +377,12 @@ func TestEveryListenerThisPackageOpensIsInTheTable(t *testing.T) {
|
||||
"ops_action_linux.go:opActionProbeUnit:net.Listen": {"tcp", 1, "the action-op probe, 127.0.0.1:0, open while one probe unit dials it"},
|
||||
"coders_linux.go:coderListenUnix:net.Listen": {"unix", 1, "a coder run's egress.sock, a unix socket file in its run dir, not a network port"},
|
||||
"coders_other.go:coderListenUnix:net.Listen": {"unix", 1, "the same, off Linux (coder mode refuses there)"},
|
||||
// The one listener on every address, by design: the door a Host that
|
||||
// this server can't reach connects to (hostdoor.go). Bound only while
|
||||
// such a pairing is served or a pairing window is open; it keeps only
|
||||
// connections whose certificate verifies under a served pairing's
|
||||
// pinned CA, and forwards nothing (TestTheDoorRefusesAnythingButItsPinnedHost).
|
||||
"hostdoor.go:listenDoor:lc.Listen": {"tcp", 1, "the door your Host connects to, every address, TCP 8812 (docs/host-connects.md)"},
|
||||
// Beside a Windows system service: the tray's own approvals window,
|
||||
// and the service's approvals pipe (approvalpipe.go), which takes no
|
||||
// remote client (PIPE_REJECT_REMOTE_CLIENTS) and, by its ACL, only
|
||||
|
||||
+19
-1
@@ -52,6 +52,11 @@ type Config struct {
|
||||
// pairing's age and not the time its credentials were last rotated (the
|
||||
// file's mtime, which is what pairings from before this field fall back to).
|
||||
FirstSeen string `json:"first_seen,omitempty"`
|
||||
// Origin: how the pairing came to this bastion, for the Host that admits
|
||||
// it (pairingsReport). "" was paired here; "carried" was moved in from an
|
||||
// account's own bastion when the machine moved to the system service
|
||||
// (servicecarry.go).
|
||||
Origin string `json:"origin,omitempty"`
|
||||
|
||||
// How this pairing reaches its host (lane_socket.go). "" is WireGuard
|
||||
// through the relay to the host's tunnel address on :8443, which is every
|
||||
@@ -66,6 +71,12 @@ type Config struct {
|
||||
LaneSocket string `json:"lane_socket,omitempty"`
|
||||
RunsDir string `json:"runs_dir,omitempty"`
|
||||
LaneAddr string `json:"lane_addr,omitempty"`
|
||||
// HostDials marks a "tcp" pairing whose Host opens the connections
|
||||
// (hostdoor.go): a Host this server can't reach, behind a home router,
|
||||
// connects to this server's door at DoorAddr, and the lanes run over the
|
||||
// connections it opens. LaneAddr is empty: nothing here ever dials it.
|
||||
HostDials bool `json:"host_dials,omitempty"`
|
||||
DoorAddr string `json:"door_addr,omitempty"`
|
||||
|
||||
// retired is set, under saveMu, once the run holding this copy has been
|
||||
// stopped or has ended (retireRun, run.go), and is never cleared; from then
|
||||
@@ -101,9 +112,13 @@ const (
|
||||
// unixTransport says whether this pairing's lanes go over a local socket.
|
||||
func (c *Config) unixTransport() bool { return c.Transport == transportUnix }
|
||||
|
||||
// tcpTransport says whether this pairing's lanes go to a Host's lane port.
|
||||
// tcpTransport says whether this pairing's lanes go to a Host's lane port,
|
||||
// or come from a Host that connects to this server (hostDials).
|
||||
func (c *Config) tcpTransport() bool { return c.Transport == transportTCP }
|
||||
|
||||
// hostDials: a tcp pairing whose Host connects to this server (hostdoor.go).
|
||||
func (c *Config) hostDials() bool { return c.tcpTransport() && c.HostDials }
|
||||
|
||||
// localTransport: a Host with no platform, over either local transport. Its
|
||||
// lanes run without a WireGuard device (run.go runUnixPairing).
|
||||
func (c *Config) localTransport() bool { return c.unixTransport() || c.tcpTransport() }
|
||||
@@ -419,6 +434,9 @@ func cmdUnpair(key string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// With the daemon running, its door's close does this too; stopped, it
|
||||
// is done here, so the rule never outlives the last pairing that used it.
|
||||
doorFirewallReleaseUnused()
|
||||
fmt.Printf("unpaired %s — its tunnel and lanes are down; the context store and DCN state stay on disk\n", host)
|
||||
return nil
|
||||
}
|
||||
|
||||
+20
-20
@@ -35,7 +35,7 @@ func TestLegacyConfigMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := map[string]string{
|
||||
"host_name": "tenant-3.host.privatae.ai",
|
||||
"host_name": "tenant-9003.host.privatae.ai",
|
||||
"bearer": "mcp_x",
|
||||
"listen_addr": "127.0.0.1:7878",
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestLegacyConfigMigration(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("loadPairings: %v", err)
|
||||
}
|
||||
if len(cfgs) != 1 || cfgs[0].HostName != "tenant-3.host.privatae.ai" {
|
||||
if len(cfgs) != 1 || cfgs[0].HostName != "tenant-9003.host.privatae.ai" {
|
||||
t.Fatalf("migrated pairings = %+v, want the one legacy host", cfgs)
|
||||
}
|
||||
if got, want := cfgs[0].ContextRoot, filepath.Join(cd, "context"); got != want {
|
||||
@@ -57,7 +57,7 @@ func TestLegacyConfigMigration(t *testing.T) {
|
||||
if got, want := cfgs[0].DCNDir, filepath.Join(cd, "dcn"); got != want {
|
||||
t.Errorf("DCNDir = %q, want the explicit legacy default %q", got, want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cd, "pairings", "tenant-3.host.privatae.ai.json")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(cd, "pairings", "tenant-9003.host.privatae.ai.json")); err != nil {
|
||||
t.Errorf("pairings/<host>.json missing after migration: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cd, "config.json")); !os.IsNotExist(err) {
|
||||
@@ -76,27 +76,27 @@ func TestLegacyConfigMigration(t *testing.T) {
|
||||
// existing pairing — both checked pre-redeem so a refusal can't burn the token.
|
||||
func TestCheckPairConflict(t *testing.T) {
|
||||
cfgs := []*Config{
|
||||
{HostName: "tenant-3.host.privatae.ai"},
|
||||
{HostName: "tenant-122.host.privatae.ai"},
|
||||
{HostName: "tenant-9003.host.privatae.ai"},
|
||||
{HostName: "tenant-9122.host.privatae.ai"},
|
||||
}
|
||||
|
||||
if _, err := checkPairConflict(cfgs, "tenant-122", false); err == nil {
|
||||
if _, err := checkPairConflict(cfgs, "tenant-9122", false); err == nil {
|
||||
t.Fatal("pairing an already-paired host must be refused without --replace")
|
||||
}
|
||||
got, err := checkPairConflict(cfgs, "tenant-122", true)
|
||||
if err != nil || got == nil || got.HostName != "tenant-122.host.privatae.ai" {
|
||||
got, err := checkPairConflict(cfgs, "tenant-9122", true)
|
||||
if err != nil || got == nil || got.HostName != "tenant-9122.host.privatae.ai" {
|
||||
t.Fatalf("--replace on a paired host = (%v, %v), want its existing pairing", got, err)
|
||||
}
|
||||
if got, err := checkPairConflict(cfgs, "tenant-9", false); err != nil || got != nil {
|
||||
if got, err := checkPairConflict(cfgs, "tenant-9500", false); err != nil || got != nil {
|
||||
t.Fatalf("a NEW host must pair cleanly, got (%v, %v)", got, err)
|
||||
}
|
||||
if _, err := checkPairConflict(cfgs, "tenant-9", true); err == nil {
|
||||
if _, err := checkPairConflict(cfgs, "tenant-9500", true); err == nil {
|
||||
t.Fatal("--replace on an unpaired host must be refused (nothing to replace)")
|
||||
}
|
||||
// "tenant-1" is a prefix of tenant-122's host name but a different handle —
|
||||
// "tenant-91" is a prefix of tenant-9122's host name but a different handle —
|
||||
// it must NOT collide.
|
||||
if got, err := checkPairConflict(cfgs, "tenant-1", false); err != nil || got != nil {
|
||||
t.Fatalf("handle 'tenant-1' must not match tenant-122, got (%v, %v)", got, err)
|
||||
if got, err := checkPairConflict(cfgs, "tenant-91", false); err != nil || got != nil {
|
||||
t.Fatalf("handle 'tenant-91' must not match tenant-9122, got (%v, %v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,13 +124,13 @@ func TestNextListenAddr(t *testing.T) {
|
||||
// prefix; ambiguity is an error, not a guess.
|
||||
func TestFindPairingHostFlag(t *testing.T) {
|
||||
cfgs := []*Config{
|
||||
{HostName: "tenant-3.host.privatae.ai"},
|
||||
{HostName: "tenant-122.host.privatae.ai"},
|
||||
{HostName: "tenant-9003.host.privatae.ai"},
|
||||
{HostName: "tenant-9122.host.privatae.ai"},
|
||||
}
|
||||
for _, key := range []string{"tenant-3.host.privatae.ai", "tenant-3", "tenant-3."} {
|
||||
for _, key := range []string{"tenant-9003.host.privatae.ai", "tenant-9003", "tenant-9003."} {
|
||||
c, err := findPairing(cfgs, key)
|
||||
if err != nil || c == nil || c.HostName != "tenant-3.host.privatae.ai" {
|
||||
t.Errorf("findPairing(%q) = (%v, %v), want tenant-3", key, c, err)
|
||||
if err != nil || c == nil || c.HostName != "tenant-9003.host.privatae.ai" {
|
||||
t.Errorf("findPairing(%q) = (%v, %v), want tenant-9003", key, c, err)
|
||||
}
|
||||
}
|
||||
if c, err := findPairing(cfgs, "nope"); err != nil || c != nil {
|
||||
@@ -145,8 +145,8 @@ func TestFindPairingHostFlag(t *testing.T) {
|
||||
// a pin synced for one host may never verify (or even be visible to) another's ops.
|
||||
func TestPerHostDcnDirs(t *testing.T) {
|
||||
cd := isolateConfigDir(t)
|
||||
a := &Config{HostName: "tenant-3.host.privatae.ai"}
|
||||
b := &Config{HostName: "tenant-122.host.privatae.ai"}
|
||||
a := &Config{HostName: "tenant-9003.host.privatae.ai"}
|
||||
b := &Config{HostName: "tenant-9122.host.privatae.ai"}
|
||||
dirA, dirB := a.dcnDir(), b.dcnDir()
|
||||
if dirA == "" || dirB == "" || dirA == dirB {
|
||||
t.Fatalf("per-host dcn dirs must differ: %q vs %q", dirA, dirB)
|
||||
|
||||
@@ -267,11 +267,20 @@ func readDoctor(readOpts) (doctorRead, error) {
|
||||
Fix: asAdmin("quarantine clear --asker " + account)})
|
||||
}
|
||||
|
||||
for _, c := range doorFirewallChecks(s.Firewall) {
|
||||
add(c)
|
||||
}
|
||||
if s.LLM != nil {
|
||||
add(doctorCheck{Name: "model server", OK: true, Detail: sentenceCase(s.LLM.words()) + "."})
|
||||
}
|
||||
for _, p := range s.Pairings {
|
||||
switch {
|
||||
case p.Problem != "":
|
||||
add(doctorCheck{Name: "host " + p.Host, Problem: "The pairing with " + p.Host + " is not usable: " +
|
||||
p.Problem + ".", Fix: "The bastion's log says more: " + logHint()})
|
||||
case p.WaitingFor != "":
|
||||
add(doctorCheck{Name: "host " + p.Host, OK: true, Detail: p.Host + " waits for " + p.WaitingFor +
|
||||
" to admit it; " + p.WaitingFor + " asks you to approve it."})
|
||||
case !p.Answering && s.Running != "no":
|
||||
add(doctorCheck{Name: "host " + p.Host, Problem: "The bastion is not serving " + p.Host +
|
||||
" (nothing answers on " + p.Local + ").", Fix: "The bastion's log says why: " + logHint()})
|
||||
|
||||
+1059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// --listapps as macOS prints it before 15 (the state on the entry's line, or
|
||||
// on the next) and from 15 (names alone, "(state: 1)" allowed, 4 blocked),
|
||||
// as shown in developer.apple.com/forums/thread/757909.
|
||||
func TestTheMacListOfAppsIsReadEntryByEntry(t *testing.T) {
|
||||
const before15 = "ALF: total number of apps = 4 \n\n" +
|
||||
"1 : /Applications/Google Chrome.app ( Block incoming connections )\n" +
|
||||
"2 : /usr/local/bin/privatae-bastion.old ( Allow incoming connections )\n" +
|
||||
"3 : /usr/local/bin/privatae-bastion \n \t ( Allow incoming connections ) \n\n" +
|
||||
"4 : /opt/example/privatae-bastion ( Block incoming connections )\n"
|
||||
apps := parseMacListApps(before15)
|
||||
if len(apps) != 4 {
|
||||
t.Fatalf("entries: %+v", apps)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
prog string
|
||||
listed, allowed bool
|
||||
}{
|
||||
{"/usr/local/bin/privatae-bastion", true, true},
|
||||
{"/opt/example/privatae-bastion", true, false},
|
||||
{"/usr/local/bin/privatae", false, false},
|
||||
{"/Applications/Google Chrome.app", true, false},
|
||||
{"/usr/local/bin/privatae-bastion.old", true, true},
|
||||
} {
|
||||
if l, a := macAppState(apps, c.prog); l != c.listed || a != c.allowed {
|
||||
t.Fatalf("%s: listed %v allowed %v", c.prog, l, a)
|
||||
}
|
||||
}
|
||||
|
||||
const from15 = "2024-06-24 16:21:15.599 socketfilterfw[2988:52866] ApplicationFirewall::AFGetAllApplications() processing response dictionary\n\n" +
|
||||
"Total number of apps = 3 \n\n" +
|
||||
"privatae-bastion.old (state: 1)\n" +
|
||||
"privatae-bastion (state: 4)\n" +
|
||||
"cupsd (state: 1)\n"
|
||||
apps = parseMacListApps(from15)
|
||||
if len(apps) != 3 {
|
||||
t.Fatalf("entries: %+v", apps)
|
||||
}
|
||||
if l, a := macAppState(apps, "/usr/local/bin/privatae-bastion"); !l || a {
|
||||
t.Fatalf("blocked by name: listed %v allowed %v", l, a)
|
||||
}
|
||||
if l, a := macAppState(apps, "/usr/sbin/cupsd"); !l || !a {
|
||||
t.Fatalf("allowed by name: listed %v allowed %v", l, a)
|
||||
}
|
||||
// MUTATION: the next line's state ignored. RESULT: red, /usr/local/bin/privatae-bastion not allowed.
|
||||
// MUTATION: any state counts as allowed. RESULT: red, blocked read as allowed.
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// systemDir is System32 on Windows; elsewhere the Windows firewall is never
|
||||
// used, and the tests read the path the rule commands are built with.
|
||||
func systemDir() string { return `C:\Windows\System32` }
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// systemDir is System32, from Windows itself rather than the environment.
|
||||
func systemDir() string {
|
||||
if d, err := windows.GetSystemDirectory(); err == nil && d != "" {
|
||||
return d
|
||||
}
|
||||
return `C:\Windows\System32`
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fwStandin plays this machine's firewall commands: netsh, ufw, firewall-cmd,
|
||||
// socketfilterfw, nft and iptables, each with the state it would keep. Every
|
||||
// command run is recorded, so a test can say what was never run.
|
||||
type fwStandin struct {
|
||||
mu sync.Mutex
|
||||
ran []string
|
||||
fail map[string]bool // a verb that fails ("add")
|
||||
|
||||
// Windows: rule name -> its add arguments.
|
||||
win map[string]string
|
||||
// ufw: on or off, and its rules as `ufw status` lists them.
|
||||
ufwOn bool
|
||||
ufwRules []string
|
||||
// firewalld: running, ports now and permanently.
|
||||
fwdOn bool
|
||||
fwdNow, fwdPerm map[string]bool
|
||||
macOn, macAllIn bool
|
||||
macApps map[string]bool
|
||||
nftRuleset, ipts string
|
||||
}
|
||||
|
||||
func newFWStandin() *fwStandin {
|
||||
return &fwStandin{fail: map[string]bool{}, win: map[string]string{}, fwdNow: map[string]bool{},
|
||||
fwdPerm: map[string]bool{}, macApps: map[string]bool{}}
|
||||
}
|
||||
|
||||
var errExit1 = errors.New("exit status 1")
|
||||
|
||||
func (f *fwStandin) exec(name string, args ...string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
line := path.Base(strings.ReplaceAll(name, `\`, "/")) + " " + strings.Join(args, " ")
|
||||
f.ran = append(f.ran, line)
|
||||
a := strings.Join(args, " ")
|
||||
switch path.Base(strings.ReplaceAll(name, `\`, "/")) {
|
||||
case "netsh.exe":
|
||||
if !strings.HasPrefix(a, "advfirewall firewall ") || len(args) < 5 {
|
||||
return "", fmt.Errorf("netsh: %s", a)
|
||||
}
|
||||
rule := strings.TrimPrefix(args[4], "name=")
|
||||
switch args[2] {
|
||||
case "show":
|
||||
if _, ok := f.win[rule]; ok {
|
||||
return "Rule Name: " + rule + "\n", nil
|
||||
}
|
||||
return "No rules match the specified criteria.\n", errExit1
|
||||
case "add":
|
||||
if f.fail["add"] {
|
||||
return "The requested operation requires elevation (Run as administrator).", errExit1
|
||||
}
|
||||
f.win[rule] = strings.Join(args[5:], " ")
|
||||
return "Ok.\n", nil
|
||||
case "delete":
|
||||
if _, ok := f.win[rule]; !ok {
|
||||
return "No rules match the specified criteria.\n", errExit1
|
||||
}
|
||||
delete(f.win, rule)
|
||||
return "\nDeleted 1 rule(s).\nOk.\n", nil
|
||||
}
|
||||
case "ufw":
|
||||
switch {
|
||||
case a == "status":
|
||||
if !f.ufwOn {
|
||||
return "Status: inactive\n", nil
|
||||
}
|
||||
return "Status: active\n\nTo Action From\n-- ------ ----\n" +
|
||||
strings.Join(f.ufwRules, "\n") + "\n", nil
|
||||
case strings.HasPrefix(a, "allow "):
|
||||
port := args[1]
|
||||
c := ""
|
||||
if len(args) == 4 && args[2] == "comment" {
|
||||
c = " # " + args[3]
|
||||
}
|
||||
f.ufwRules = append(f.ufwRules, port+" ALLOW Anywhere"+c,
|
||||
port+" (v6) ALLOW Anywhere (v6)"+c)
|
||||
return "Rule added\nRule added (v6)\n", nil
|
||||
case strings.HasPrefix(a, "delete allow "):
|
||||
port := args[2]
|
||||
var keep []string
|
||||
for _, r := range f.ufwRules {
|
||||
if strings.Fields(r)[0] != port {
|
||||
keep = append(keep, r)
|
||||
}
|
||||
}
|
||||
if len(keep) == len(f.ufwRules) {
|
||||
return "Could not delete non-existent rule\n", errExit1
|
||||
}
|
||||
f.ufwRules = keep
|
||||
return "Rule deleted\nRule deleted (v6)\n", nil
|
||||
}
|
||||
case "firewall-cmd":
|
||||
perm := len(args) > 0 && args[0] == "--permanent"
|
||||
set := f.fwdNow
|
||||
if perm {
|
||||
set, args = f.fwdPerm, args[1:]
|
||||
}
|
||||
if len(args) == 0 {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case args[0] == "--state":
|
||||
if f.fwdOn {
|
||||
return "running\n", nil
|
||||
}
|
||||
return "not running\n", errors.New("exit status 252")
|
||||
case strings.HasPrefix(args[0], "--query-port="):
|
||||
if set[strings.TrimPrefix(args[0], "--query-port=")] {
|
||||
return "yes\n", nil
|
||||
}
|
||||
return "no\n", errExit1
|
||||
case strings.HasPrefix(args[0], "--add-port="):
|
||||
if perm && f.fail["permanent"] {
|
||||
return "Error: COMMAND_FAILED\n", errExit1
|
||||
}
|
||||
set[strings.TrimPrefix(args[0], "--add-port=")] = true
|
||||
return "success\n", nil
|
||||
case strings.HasPrefix(args[0], "--remove-port="):
|
||||
p := strings.TrimPrefix(args[0], "--remove-port=")
|
||||
if !set[p] {
|
||||
return "Warning: NOT_ENABLED: " + p + "\nsuccess\n", nil
|
||||
}
|
||||
delete(set, p)
|
||||
return "success\n", nil
|
||||
}
|
||||
case "socketfilterfw":
|
||||
switch args[0] {
|
||||
case "--getglobalstate":
|
||||
if f.macOn {
|
||||
return "Firewall is enabled. (State = 1)\n", nil
|
||||
}
|
||||
return "Firewall is disabled. (State = 0)\n", nil
|
||||
case "--getblockall":
|
||||
if f.macAllIn {
|
||||
return "Firewall is set to block all non-essential incoming connections\nBlock all ENABLED!\n", nil
|
||||
}
|
||||
return "Block all DISABLED!\n", nil
|
||||
case "--listapps":
|
||||
// As macOS before 15 prints it (developer.apple.com/forums/thread/757909).
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "ALF: total number of apps = %d \n\n", len(f.macApps))
|
||||
var ps []string
|
||||
for p := range f.macApps {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
sort.Strings(ps)
|
||||
for i, p := range ps {
|
||||
st := "Block"
|
||||
if f.macApps[p] {
|
||||
st = "Allow"
|
||||
}
|
||||
fmt.Fprintf(&b, "%d : %s ( %s incoming connections )\n", i+1, p, st)
|
||||
}
|
||||
return b.String(), nil
|
||||
case "--add":
|
||||
f.macApps[args[1]] = false
|
||||
return "The file " + args[1] + " added to firewall\n", nil
|
||||
case "--unblockapp":
|
||||
if f.fail["unblock"] {
|
||||
return "Error\n", errExit1
|
||||
}
|
||||
f.macApps[args[1]] = true
|
||||
return "Incoming connection to the application is permitted\n", nil
|
||||
case "--remove":
|
||||
delete(f.macApps, args[1])
|
||||
return "The file " + args[1] + " removed from firewall\n", nil
|
||||
}
|
||||
case "nft":
|
||||
return f.nftRuleset, nil
|
||||
case "iptables":
|
||||
return f.ipts, nil
|
||||
}
|
||||
return "", fmt.Errorf("the stand-in does not know %s", line)
|
||||
}
|
||||
|
||||
func (f *fwStandin) commands() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.ran...)
|
||||
}
|
||||
|
||||
// fwWorld stands the firewall in for goos, with the door wanted while *want.
|
||||
func fwWorld(t *testing.T, goos string, root bool) (*fwStandin, *bool) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
f := newFWStandin()
|
||||
want := true
|
||||
prevExec, prevFor, prevBy, prevHave, prevRoot, prevProg, prevWant, prevRead :=
|
||||
doorFWExec, doorFirewallFor, doorFirewallByID, doorFWHave, doorFWRoot, doorFWProgram, doorPortWanted, doorFWReadFile
|
||||
t.Cleanup(func() {
|
||||
doorFWExec, doorFirewallFor, doorFirewallByID, doorFWHave, doorFWRoot, doorFWProgram, doorPortWanted, doorFWReadFile =
|
||||
prevExec, prevFor, prevBy, prevHave, prevRoot, prevProg, prevWant, prevRead
|
||||
})
|
||||
doorFWExec = f.exec
|
||||
doorFirewallFor = func(port string) (doorFirewall, string) { return detectDoorFirewall(goos, port) }
|
||||
doorFirewallByID = func(id string) doorFirewall { return doorFirewallOf(goos, id) }
|
||||
doorFWHave = func(paths ...string) string { return paths[0] }
|
||||
doorFWRoot = func() bool { return root }
|
||||
doorFWProgram = func() (string, error) { return `C:\Program Files\Privatae\privatae-bastion.exe`, nil }
|
||||
doorPortWanted = func(string) bool { return want }
|
||||
doorFWReadFile = func(string) ([]byte, error) { return nil, errors.New("no such file") }
|
||||
return f, &want
|
||||
}
|
||||
|
||||
func fwAudit(t *testing.T) string {
|
||||
t.Helper()
|
||||
d, _ := configDir()
|
||||
b, _ := os.ReadFile(filepath.Join(d, "pairing_audit.jsonl"))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func countOp(t *testing.T, op string) int {
|
||||
return strings.Count(fwAudit(t), `"op":"`+op+`"`)
|
||||
}
|
||||
|
||||
// Windows: the rule is the bastion's own by name, inbound, TCP on the door's
|
||||
// port only, every profile, for this program only, made by netsh from
|
||||
// System32. It is made once, kept while the door is in use, and removed when
|
||||
// the last pairing and window are gone, each step in the audit and in status.
|
||||
func TestTheWindowsRuleIsTheBastionsOwnAndGoesWithTheDoor(t *testing.T) {
|
||||
f, want := fwWorld(t, "windows", false)
|
||||
said := doorFirewallEnsure(":8812")
|
||||
rule := doorFWRuleName("8812")
|
||||
got, ok := f.win[rule]
|
||||
if !ok {
|
||||
t.Fatalf("no rule %q: %v", rule, f.commands())
|
||||
}
|
||||
for _, part := range []string{"dir=in", "action=allow", "protocol=TCP", "localport=8812",
|
||||
`program=C:\Program Files\Privatae\privatae-bastion.exe`, "profile=any", "enable=yes"} {
|
||||
if !strings.Contains(got, part) {
|
||||
t.Fatalf("the rule lacks %s: %s", part, got)
|
||||
}
|
||||
}
|
||||
for _, c := range f.commands() {
|
||||
if !strings.HasPrefix(c, "netsh.exe advfirewall firewall ") {
|
||||
t.Fatalf("ran %q", c)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(netshPath(), `C:\Windows\System32`) {
|
||||
t.Fatalf("netsh is not run by its absolute path: %s", netshPath())
|
||||
}
|
||||
if !strings.Contains(said, "added its own rule in Windows Firewall") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
// Again (the daemon's door after the window's): kept, not doubled.
|
||||
doorFirewallEnsure(":8812")
|
||||
if n := strings.Count(strings.Join(f.commands(), "\n"), " add rule "); n != 1 {
|
||||
t.Fatalf("%d adds", n)
|
||||
}
|
||||
s := readDoorFirewallStatus()
|
||||
if s == nil || len(s.Rules) != 1 || !strings.Contains(s.Rules[0], "Windows Firewall allows TCP 8812") {
|
||||
t.Fatalf("status: %+v", s)
|
||||
}
|
||||
// Still in use: the rule stays.
|
||||
doorFirewallRelease(":8812")
|
||||
if _, ok := f.win[rule]; !ok {
|
||||
t.Fatal("removed while the door is still in use")
|
||||
}
|
||||
// The last pairing and window gone: removed.
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if _, ok := f.win[rule]; ok {
|
||||
t.Fatal("the rule outlived the door")
|
||||
}
|
||||
if s := readDoorFirewallStatus(); s != nil {
|
||||
t.Fatalf("status after: %+v", s)
|
||||
}
|
||||
if countOp(t, "door_firewall_opened") != 1 || countOp(t, "door_firewall_closed") != 1 {
|
||||
t.Fatalf("the audit:\n%s", fwAudit(t))
|
||||
}
|
||||
// MUTATION: release removes nothing. RESULT: red, "the rule outlived the door".
|
||||
// MUTATION: the add drops program=. RESULT: red, "the rule lacks program=".
|
||||
// MUTATION: ensure adds without asking has(). RESULT: red, "2 adds".
|
||||
}
|
||||
|
||||
// Windows: the bastion moved (another path): its rule is replaced by one for
|
||||
// the new path; and an add refused (no administrator) is said once, with the
|
||||
// command that does it by hand, in status and doctor.
|
||||
func TestTheWindowsRuleFollowsTheProgramAndAFailureIsSaidOnce(t *testing.T) {
|
||||
f, _ := fwWorld(t, "windows", false)
|
||||
doorFirewallEnsure(":8812")
|
||||
doorFWProgram = func() (string, error) { return `D:\Privatae\privatae-bastion.exe`, nil }
|
||||
doorFirewallEnsure(":8812")
|
||||
if got := f.win[doorFWRuleName("8812")]; !strings.Contains(got, `program=D:\Privatae\privatae-bastion.exe`) {
|
||||
t.Fatalf("the rule names %s", got)
|
||||
}
|
||||
if st := loadDoorFW(); len(st.Rules) != 1 || st.Rules[0].Program != `D:\Privatae\privatae-bastion.exe` {
|
||||
t.Fatalf("the record: %+v", st.Rules)
|
||||
}
|
||||
|
||||
g, _ := fwWorld(t, "windows", false)
|
||||
g.fail["add"] = true
|
||||
doorFirewallEnsure(":8812")
|
||||
doorFirewallEnsure(":8812")
|
||||
if n := countOp(t, "door_firewall_failed"); n != 1 {
|
||||
t.Fatalf("the failure audited %d times", n)
|
||||
}
|
||||
s := readDoorFirewallStatus()
|
||||
if s == nil || len(s.Needs) != 1 || !strings.Contains(s.Needs[0], "New-NetFirewallRule") {
|
||||
t.Fatalf("status: %+v", s)
|
||||
}
|
||||
cs := doorFirewallChecks(s)
|
||||
if len(cs) != 1 || cs[0].OK || !strings.Contains(cs[0].Problem, "TCP 8812") {
|
||||
t.Fatalf("doctor: %+v", cs)
|
||||
}
|
||||
// MUTATION: the program is not compared. RESULT: red, the rule names the old path.
|
||||
// MUTATION: the note is audited on every ensure. RESULT: red, audited 2 times.
|
||||
}
|
||||
|
||||
// Linux ufw: a rule only while ufw is on, with the bastion's comment; ufw is
|
||||
// never turned on, off or reset; a rule of the machine's own for the port is
|
||||
// neither doubled nor removed.
|
||||
func TestTheUfwRuleIsAddedOnlyWhenUfwIsOnAndNeverTheMachinesOwn(t *testing.T) {
|
||||
f, want := fwWorld(t, "linux", true)
|
||||
// Off: nothing at all.
|
||||
if said := doorFirewallEnsure(":8812"); said != "" || len(loadDoorFW().Rules) != 0 {
|
||||
t.Fatalf("ufw off: %q %v", said, f.commands())
|
||||
}
|
||||
f.ufwOn = true
|
||||
doorFirewallEnsure(":8812")
|
||||
st := strings.Join(f.ufwRules, "\n")
|
||||
if !strings.Contains(st, "8812/tcp") || !strings.Contains(st, "# "+doorFWComment) {
|
||||
t.Fatalf("ufw rules: %s", st)
|
||||
}
|
||||
if s := readDoorFirewallStatus(); s == nil || len(s.Rules) != 1 || !strings.Contains(s.Rules[0], "ufw allows TCP 8812") {
|
||||
t.Fatalf("status: %+v", s)
|
||||
}
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if len(f.ufwRules) != 0 {
|
||||
t.Fatalf("left: %v", f.ufwRules)
|
||||
}
|
||||
for _, c := range f.commands() {
|
||||
for _, never := range []string{"ufw enable", "ufw disable", "ufw reset", "ufw default", "ufw reload"} {
|
||||
if strings.HasPrefix(c, never) {
|
||||
t.Fatalf("ran %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's own rule for the port.
|
||||
g, want2 := fwWorld(t, "linux", true)
|
||||
g.ufwOn = true
|
||||
g.ufwRules = []string{"8812/tcp ALLOW Anywhere", "8812/tcp (v6) ALLOW Anywhere (v6)"}
|
||||
said := doorFirewallEnsure(":8812")
|
||||
if !strings.Contains(said, "already allowed") || len(g.ufwRules) != 2 {
|
||||
t.Fatalf("said %q, rules %v", said, g.ufwRules)
|
||||
}
|
||||
*want2 = false
|
||||
doorFirewallRelease(":8812")
|
||||
if len(g.ufwRules) != 2 {
|
||||
t.Fatalf("the machine's own rule was removed: %v", g.commands())
|
||||
}
|
||||
// MUTATION: ufwRuleFor counts any ALLOW as ours. RESULT: red, the machine's own rule was removed.
|
||||
// MUTATION: detection ignores "Status: inactive". RESULT: red, ufw off.
|
||||
}
|
||||
|
||||
// Linux firewalld: the port added now and permanently, and removed the same
|
||||
// way; a port already open in it is the machine's own.
|
||||
func TestTheFirewalldPortIsAddedAndRemovedNowAndPermanently(t *testing.T) {
|
||||
f, want := fwWorld(t, "linux", true)
|
||||
f.fwdOn = true
|
||||
doorFirewallEnsure(":8812")
|
||||
if !f.fwdNow["8812/tcp"] || !f.fwdPerm["8812/tcp"] {
|
||||
t.Fatalf("firewalld: %v %v", f.fwdNow, f.fwdPerm)
|
||||
}
|
||||
doorFirewallEnsure(":8812")
|
||||
if n := strings.Count(strings.Join(f.commands(), "\n"), "--add-port"); n != 2 {
|
||||
t.Fatalf("%d adds: %v", n, f.commands())
|
||||
}
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if f.fwdNow["8812/tcp"] || f.fwdPerm["8812/tcp"] {
|
||||
t.Fatalf("left: %v %v", f.fwdNow, f.fwdPerm)
|
||||
}
|
||||
|
||||
g, want2 := fwWorld(t, "linux", true)
|
||||
g.fwdOn = true
|
||||
g.fwdNow["8812/tcp"], g.fwdPerm["8812/tcp"] = true, true
|
||||
doorFirewallEnsure(":8812")
|
||||
*want2 = false
|
||||
doorFirewallRelease(":8812")
|
||||
if !g.fwdNow["8812/tcp"] || !g.fwdPerm["8812/tcp"] {
|
||||
t.Fatal("the machine's own open port was closed")
|
||||
}
|
||||
// MUTATION: remove only the runtime port. RESULT: red, left permanently.
|
||||
// MUTATION: has() reports a port it did not add as ours. RESULT: red, the machine's own port closed.
|
||||
}
|
||||
|
||||
// Linux: a firewall the bastion does not manage (nftables, iptables), or ufw
|
||||
// it may not change, is said once, with what to allow, in status and doctor,
|
||||
// and nothing in it is changed.
|
||||
func TestAFirewallTheBastionDoesNotManageIsSaidOnceAndLeftAlone(t *testing.T) {
|
||||
f, want := fwWorld(t, "linux", true)
|
||||
f.nftRuleset = "table inet filter {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t}\n}\n"
|
||||
said := doorFirewallEnsure(":8812")
|
||||
doorFirewallEnsure(":8812")
|
||||
if !strings.Contains(said, "nftables") || !strings.Contains(said, "tcp dport 8812 accept") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
if n := countOp(t, "door_firewall_unmanaged"); n != 1 {
|
||||
t.Fatalf("said %d times", n)
|
||||
}
|
||||
for _, c := range f.commands() {
|
||||
if strings.HasPrefix(c, "nft ") && c != "nft list ruleset" {
|
||||
t.Fatalf("ran %q", c)
|
||||
}
|
||||
}
|
||||
s := readDoorFirewallStatus()
|
||||
if s == nil || len(s.Needs) != 1 || !strings.Contains(s.Needs[0], "nftables") {
|
||||
t.Fatalf("status: %+v", s)
|
||||
}
|
||||
if cs := doorFirewallChecks(s); len(cs) != 1 || cs[0].OK {
|
||||
t.Fatalf("doctor: %+v", cs)
|
||||
}
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if s := readDoorFirewallStatus(); s != nil {
|
||||
t.Fatalf("still said with no door: %+v", s)
|
||||
}
|
||||
|
||||
// iptables, and an account that may not change ufw.
|
||||
g, _ := fwWorld(t, "linux", true)
|
||||
g.ipts = "-P INPUT DROP\n"
|
||||
if said := doorFirewallEnsure(":8812"); !strings.Contains(said, "iptables -I INPUT -p tcp --dport 8812 -j ACCEPT") {
|
||||
t.Fatalf("iptables: %q", said)
|
||||
}
|
||||
h, _ := fwWorld(t, "linux", false)
|
||||
doorFWReadFile = func(string) ([]byte, error) { return []byte("ENABLED=yes\n"), nil }
|
||||
if said := doorFirewallEnsure(":8812"); !strings.Contains(said, "sudo ufw allow 8812/tcp") {
|
||||
t.Fatalf("not root: %q", said)
|
||||
}
|
||||
for _, c := range h.commands() {
|
||||
if strings.HasPrefix(c, "ufw ") {
|
||||
t.Fatalf("an account that may not change ufw ran %q", c)
|
||||
}
|
||||
}
|
||||
// MUTATION: nftFiltersInput always false. RESULT: red, said "".
|
||||
// MUTATION: the note is not cleared at release. RESULT: red, still said with no door.
|
||||
}
|
||||
|
||||
// macOS: with the application firewall on, this program is allowed; with it
|
||||
// off nothing is done; "block all incoming" is only said.
|
||||
func TestTheMacFirewallAllowsTheProgramOnlyWhenItIsOn(t *testing.T) {
|
||||
f, want := fwWorld(t, "darwin", true)
|
||||
doorFirewallEnsure(":8812")
|
||||
if len(f.macApps) != 0 {
|
||||
t.Fatal("added with the firewall off")
|
||||
}
|
||||
f.macOn = true
|
||||
doorFirewallEnsure(":8812")
|
||||
prog, _ := doorFWProgram()
|
||||
if allowed, ok := f.macApps[prog]; !ok || !allowed {
|
||||
t.Fatalf("apps: %v", f.macApps)
|
||||
}
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if len(f.macApps) != 0 {
|
||||
t.Fatalf("left: %v", f.macApps)
|
||||
}
|
||||
g, _ := fwWorld(t, "darwin", true)
|
||||
g.macOn, g.macAllIn = true, true
|
||||
if said := doorFirewallEnsure(":8812"); !strings.Contains(said, "blocks all incoming") || len(g.macApps) != 0 {
|
||||
t.Fatalf("block all: %q %v", said, g.macApps)
|
||||
}
|
||||
// MUTATION: --unblockapp not run. RESULT: red, apps not allowed.
|
||||
}
|
||||
|
||||
// uninstall removes every rule the bastion added, whatever uses the door.
|
||||
func TestUninstallRemovesTheDoorsOwnRules(t *testing.T) {
|
||||
f, _ := fwWorld(t, "windows", false)
|
||||
doorFirewallEnsure(":8812")
|
||||
doorFirewallEnsure(":9443")
|
||||
said, err := doorFirewallRemoveAll()
|
||||
if err != nil || len(said) != 2 || len(f.win) != 0 {
|
||||
t.Fatalf("uninstall: %v %v, left %v", said, err, f.win)
|
||||
}
|
||||
if len(loadDoorFW().Rules) != 0 {
|
||||
t.Fatal("the record outlived the rules")
|
||||
}
|
||||
// MUTATION: RemoveAll skips the removal. RESULT: red, rules left.
|
||||
}
|
||||
|
||||
// A rule left behind (a `pair --open` killed before its release, a pairing
|
||||
// that went while nothing ran) is removed by the daemon's sweep once nothing
|
||||
// uses its port, and kept while something does.
|
||||
func TestTheDaemonsSweepRemovesARuleNothingUses(t *testing.T) {
|
||||
f, want := fwWorld(t, "windows", false)
|
||||
doorFirewallEnsure(":8812")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { doorFirewallSweep(ctx); close(done) }()
|
||||
cancel()
|
||||
<-done
|
||||
if _, ok := f.win[doorFWRuleName("8812")]; !ok {
|
||||
t.Fatal("the sweep removed a rule in use")
|
||||
}
|
||||
*want = false
|
||||
doorFirewallReleaseUnused()
|
||||
if _, ok := f.win[doorFWRuleName("8812")]; ok {
|
||||
t.Fatal("the sweep left a rule nothing uses")
|
||||
}
|
||||
// MUTATION: doorFirewallReleaseUnused skips recorded rules. RESULT: red, the sweep left a rule.
|
||||
}
|
||||
|
||||
// ufw deletes by rule, whatever its comment: a rule the bastion recorded and
|
||||
// the person later replaced with their own, or a record that names a port the
|
||||
// bastion never opened, is the machine's, and is never removed.
|
||||
func TestUfwNeverRemovesARuleThatIsNotItsOwnWhateverTheRecordSays(t *testing.T) {
|
||||
f, want := fwWorld(t, "linux", true)
|
||||
f.ufwOn = true
|
||||
doorFirewallEnsure(":8812")
|
||||
// The person deletes the bastion's rule and puts their own in its place.
|
||||
f.ufwRules = []string{"8812/tcp ALLOW Anywhere", "8812/tcp (v6) ALLOW Anywhere (v6)"}
|
||||
doorFirewallEnsure(":8812")
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if len(f.ufwRules) != 2 {
|
||||
t.Fatalf("the person's own rule was removed: %v", f.commands())
|
||||
}
|
||||
|
||||
// A record naming a port the bastion never opened (written by hand, or
|
||||
// left from another time): the sweep leaves the machine's rule there.
|
||||
g, want2 := fwWorld(t, "linux", true)
|
||||
g.ufwOn = true
|
||||
g.ufwRules = []string{"22/tcp ALLOW Anywhere"}
|
||||
if err := saveDoorFW(doorFWState{Rules: []doorFWRule{{Firewall: "ufw", Port: "22", Added: "2026-01-01T00:00:00Z"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*want2 = false
|
||||
doorFirewallReleaseUnused()
|
||||
for _, c := range g.commands() {
|
||||
if strings.HasPrefix(c, "ufw delete") {
|
||||
t.Fatalf("ran %q on a rule that is not the bastion's", c)
|
||||
}
|
||||
}
|
||||
if len(g.ufwRules) != 1 || len(loadDoorFW().Rules) != 0 {
|
||||
t.Fatalf("rules %v, record %+v", g.ufwRules, loadDoorFW().Rules)
|
||||
}
|
||||
// MUTATION: ufw remove deletes without reading the comment. RESULT: red, the person's own rule was removed.
|
||||
}
|
||||
|
||||
// Only a rule that lets a Host in from anywhere counts as the machine's own
|
||||
// allow: an outbound rule, or one for one source or interface, does not, so
|
||||
// the bastion adds its own rather than saying the port is already allowed.
|
||||
func TestUfwAnOutboundOrOneSourceRuleIsNotTheWayIn(t *testing.T) {
|
||||
for _, r := range []string{
|
||||
"8812/tcp ALLOW OUT Anywhere",
|
||||
"8812/tcp ALLOW 10.0.0.0/8",
|
||||
"8812/tcp on eth1 ALLOW Anywhere",
|
||||
"8812/tcp DENY Anywhere",
|
||||
} {
|
||||
f, _ := fwWorld(t, "linux", true)
|
||||
f.ufwOn = true
|
||||
f.ufwRules = []string{r}
|
||||
said := doorFirewallEnsure(":8812")
|
||||
if !strings.Contains(said, "added its own rule in ufw") {
|
||||
t.Fatalf("with %q: said %q", r, said)
|
||||
}
|
||||
}
|
||||
for _, r := range []string{
|
||||
"8812/tcp ALLOW Anywhere",
|
||||
"8812/tcp ALLOW IN Anywhere",
|
||||
"8812 (v6) ALLOW Anywhere (v6)",
|
||||
} {
|
||||
if _, other := ufwRuleFor(r, "8812"); !other {
|
||||
t.Fatalf("%q lets a Host in from anywhere", r)
|
||||
}
|
||||
}
|
||||
// MUTATION: any line with ALLOW counts. RESULT: red, said "already allowed".
|
||||
}
|
||||
|
||||
// firewalld: a permanent add that fails takes the runtime port back out, so
|
||||
// no open port is left that no record says is the bastion's.
|
||||
func TestFirewalldAFailedPermanentAddLeavesNoPortOpen(t *testing.T) {
|
||||
f, _ := fwWorld(t, "linux", true)
|
||||
f.fwdOn = true
|
||||
f.fail["permanent"] = true
|
||||
said := doorFirewallEnsure(":8812")
|
||||
if !strings.Contains(said, "could not add") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
if f.fwdNow["8812/tcp"] {
|
||||
t.Fatalf("the runtime port was left open with no record: %v", f.commands())
|
||||
}
|
||||
// Next time it is tried again, not taken for the machine's own.
|
||||
f.fail["permanent"] = false
|
||||
if said := doorFirewallEnsure(":8812"); !strings.Contains(said, "added its own rule") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
// MUTATION: no undo of the runtime add. RESULT: red, the runtime port was left open.
|
||||
}
|
||||
|
||||
// macOS: an --unblockapp that fails takes the --add back out, so no entry is
|
||||
// left that would read as the machine's own and never go.
|
||||
func TestTheMacAFailedUnblockLeavesNoEntry(t *testing.T) {
|
||||
f, _ := fwWorld(t, "darwin", true)
|
||||
f.macOn = true
|
||||
f.fail["unblock"] = true
|
||||
if said := doorFirewallEnsure(":8812"); !strings.Contains(said, "could not add") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
if len(f.macApps) != 0 {
|
||||
t.Fatalf("left an entry with no record: %v", f.macApps)
|
||||
}
|
||||
// MUTATION: no --remove after a failed --unblockapp. RESULT: red, left an entry.
|
||||
}
|
||||
|
||||
// realDoorPortWanted is the door-in-use check fwWorld stands in.
|
||||
var realDoorPortWanted = doorPortWanted
|
||||
|
||||
// sweepOnce runs the daemon's sweep's first pass.
|
||||
func sweepOnce() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
doorFirewallSweep(ctx)
|
||||
}
|
||||
|
||||
// A door claims the window (its file is moved aside) before the pairing is
|
||||
// saved: between the two, neither is there to read. The window is still in
|
||||
// use then, so a sweep in that gap keeps the rule, and puts it back if it had
|
||||
// gone; once the window has its result and no pairing uses the port, the
|
||||
// rule goes.
|
||||
func TestAWindowClaimedForAPairingStillBeingSavedKeepsTheRule(t *testing.T) {
|
||||
f, _ := fwWorld(t, "windows", false)
|
||||
doorPortWanted = realDoorPortWanted
|
||||
code, err := newWindowCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
w := &pairWindow{ID: "w-claimed", Code: code, Door: ":8812", Opened: now.Format(time.RFC3339),
|
||||
Expires: now.Add(windowLifetime).Format(time.RFC3339)}
|
||||
if err := saveWindowFile(w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doorFirewallEnsure(":8812")
|
||||
rule := doorFWRuleName("8812")
|
||||
if _, ok := f.win[rule]; !ok {
|
||||
t.Fatalf("no rule for the open window: %v", f.commands())
|
||||
}
|
||||
if !claimWindow(w.ID) {
|
||||
t.Fatal("the window could not be claimed")
|
||||
}
|
||||
sweepOnce()
|
||||
if _, ok := f.win[rule]; !ok {
|
||||
t.Fatal("the sweep removed the rule while a pairing on the window was being saved")
|
||||
}
|
||||
// The rule gone in that gap (by hand, or a release that ran then): back.
|
||||
delete(f.win, rule)
|
||||
sweepOnce()
|
||||
if _, ok := f.win[rule]; !ok {
|
||||
t.Fatal("the sweep did not put back the rule of a window still in use")
|
||||
}
|
||||
endWindow(w.ID, windowResult{Error: "the Host refused"})
|
||||
sweepOnce()
|
||||
if _, ok := f.win[rule]; ok {
|
||||
t.Fatal("the rule outlived the window")
|
||||
}
|
||||
// MUTATION: doorPortsInUse ignores a claimed window. RESULT: red, the sweep removed the rule while a pairing ... was being saved.
|
||||
// MUTATION: claimedWindowUnderWay counts a window with its result. RESULT: red, the rule outlived the window.
|
||||
}
|
||||
|
||||
// A rule that goes while its door is in use (removed by hand, the firewall
|
||||
// reset) is put back by the daemon's sweep; a port a rule of the machine's
|
||||
// own allows is still never given a second one; nothing in use, nothing added.
|
||||
func TestTheSweepPutsBackARuleThatWentWhileTheDoorIsInUse(t *testing.T) {
|
||||
f, want := fwWorld(t, "windows", false)
|
||||
doorFirewallEnsure(":8812")
|
||||
rule := doorFWRuleName("8812")
|
||||
delete(f.win, rule) // removed by hand
|
||||
sweepOnce()
|
||||
if got, ok := f.win[rule]; !ok || !strings.Contains(got, "localport=8812") {
|
||||
t.Fatalf("not put back: %v", f.commands())
|
||||
}
|
||||
if n := countOp(t, "door_firewall_opened"); n != 2 {
|
||||
t.Fatalf("opened %d times:\n%s", n, fwAudit(t))
|
||||
}
|
||||
*want = false
|
||||
sweepOnce()
|
||||
if _, ok := f.win[rule]; ok {
|
||||
t.Fatal("a rule nothing uses was kept")
|
||||
}
|
||||
sweepOnce()
|
||||
if _, ok := f.win[rule]; ok {
|
||||
t.Fatal("the sweep added a rule nothing uses")
|
||||
}
|
||||
|
||||
// ufw: the bastion's rule deleted while the door is in use, back with its comment.
|
||||
g, _ := fwWorld(t, "linux", true)
|
||||
g.ufwOn = true
|
||||
doorFirewallEnsure(":8812")
|
||||
g.ufwRules = nil
|
||||
sweepOnce()
|
||||
if st := strings.Join(g.ufwRules, "\n"); !strings.Contains(st, "8812/tcp") || !strings.Contains(st, "# "+doorFWComment) {
|
||||
t.Fatalf("ufw not put back: %v", g.commands())
|
||||
}
|
||||
|
||||
// ufw: the machine's own rule allows the port: never a second one.
|
||||
h, _ := fwWorld(t, "linux", true)
|
||||
h.ufwOn = true
|
||||
h.ufwRules = []string{"8812/tcp ALLOW Anywhere"}
|
||||
doorFirewallEnsure(":8812")
|
||||
sweepOnce()
|
||||
sweepOnce()
|
||||
for _, c := range h.commands() {
|
||||
if strings.HasPrefix(c, "ufw allow") {
|
||||
t.Fatalf("ran %q with the machine's own rule there", c)
|
||||
}
|
||||
}
|
||||
// MUTATION: the sweep only releases. RESULT: red, not put back (and in
|
||||
// TestAWindowClaimedForAPairingStillBeingSavedKeepsTheRule).
|
||||
}
|
||||
|
||||
// macOS: an entry is for one program path; the bastion that moved removes the
|
||||
// entry by the path it recorded, both when its door is released and when it
|
||||
// makes the entry for its new path.
|
||||
func TestTheMacEntryIsRemovedByThePathItWasAddedFor(t *testing.T) {
|
||||
const a, b = "/usr/local/bin/privatae-bastion", "/opt/example/privatae-bastion"
|
||||
f, want := fwWorld(t, "darwin", true)
|
||||
f.macOn = true
|
||||
doorFWProgram = func() (string, error) { return a, nil }
|
||||
doorFirewallEnsure(":8812")
|
||||
if !f.macApps[a] {
|
||||
t.Fatalf("apps: %v", f.macApps)
|
||||
}
|
||||
doorFWProgram = func() (string, error) { return b, nil }
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if len(f.macApps) != 0 {
|
||||
t.Fatalf("the moved bastion left its old path allowed: %v", f.macApps)
|
||||
}
|
||||
|
||||
// Moved while the door is in use: the old path's entry goes, the new one's is made.
|
||||
g, _ := fwWorld(t, "darwin", true)
|
||||
g.macOn = true
|
||||
doorFWProgram = func() (string, error) { return a, nil }
|
||||
doorFirewallEnsure(":8812")
|
||||
doorFWProgram = func() (string, error) { return b, nil }
|
||||
doorFirewallEnsure(":8812")
|
||||
if _, ok := g.macApps[a]; ok || !g.macApps[b] {
|
||||
t.Fatalf("apps: %v", g.macApps)
|
||||
}
|
||||
if st := loadDoorFW(); len(st.Rules) != 1 || st.Rules[0].Program != b {
|
||||
t.Fatalf("the record: %+v", st.Rules)
|
||||
}
|
||||
// MUTATION: remove uses this program's path, not the record's. RESULT: red, the moved bastion left its old path allowed.
|
||||
// MUTATION: the moved case removes this program's path. RESULT: red, apps: both paths.
|
||||
}
|
||||
|
||||
// macOS: --listapps is read entry by entry, by the exact path, and only an
|
||||
// "allow incoming" entry counts: a blocked entry is the person's setting and
|
||||
// is said, not changed; a longer path (…/privatae-bastion.old) is not this
|
||||
// program's.
|
||||
func TestTheMacReadsEachEntryAndCountsOnlyAnAllow(t *testing.T) {
|
||||
const prog = "/usr/local/bin/privatae-bastion"
|
||||
f, _ := fwWorld(t, "darwin", true)
|
||||
f.macOn = true
|
||||
doorFWProgram = func() (string, error) { return prog, nil }
|
||||
f.macApps[prog] = false // the person blocked it
|
||||
said := doorFirewallEnsure(":8812")
|
||||
if !strings.Contains(said, "block incoming connections for this program") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
for _, c := range f.commands() {
|
||||
if strings.Contains(c, "--unblockapp") || strings.Contains(c, "--add") || strings.Contains(c, "--remove") {
|
||||
t.Fatalf("changed the person's setting: %q", c)
|
||||
}
|
||||
}
|
||||
if s := readDoorFirewallStatus(); s == nil || len(s.Needs) != 1 {
|
||||
t.Fatalf("status: %+v", s)
|
||||
}
|
||||
|
||||
g, _ := fwWorld(t, "darwin", true)
|
||||
g.macOn = true
|
||||
doorFWProgram = func() (string, error) { return prog, nil }
|
||||
g.macApps[prog+".old"] = true
|
||||
if said := doorFirewallEnsure(":8812"); !strings.Contains(said, "added its own rule") || !g.macApps[prog] {
|
||||
t.Fatalf("a longer path counted as this program: said %q, apps %v", said, g.macApps)
|
||||
}
|
||||
|
||||
// Its own entry, later set to block by the person: left as it is at release.
|
||||
h, want := fwWorld(t, "darwin", true)
|
||||
h.macOn = true
|
||||
doorFWProgram = func() (string, error) { return prog, nil }
|
||||
doorFirewallEnsure(":8812")
|
||||
h.macApps[prog] = false
|
||||
*want = false
|
||||
doorFirewallRelease(":8812")
|
||||
if allowed, ok := h.macApps[prog]; !ok || allowed {
|
||||
t.Fatalf("the person's block was removed: %v", h.macApps)
|
||||
}
|
||||
// MUTATION: has() counts a listed program whatever its state. RESULT: red, said "already allowed".
|
||||
// MUTATION: the path is matched as a prefix. RESULT: red, a longer path counted as this program.
|
||||
// MUTATION: remove runs --remove on a blocked entry. RESULT: red, the person's block was removed.
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fwFake is a firewall the bastion manages, kept in memory.
|
||||
type fwFake struct {
|
||||
mu sync.Mutex
|
||||
rules map[string]bool
|
||||
adds int
|
||||
}
|
||||
|
||||
func (f *fwFake) id() string { return "ufw" }
|
||||
func (f *fwFake) name() string { return "ufw" }
|
||||
func (f *fwFake) has(port string) (bool, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.rules[port], false, nil
|
||||
}
|
||||
func (f *fwFake) add(port string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.rules[port] = true
|
||||
f.adds++
|
||||
return nil
|
||||
}
|
||||
func (f *fwFake) remove(port, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.rules[port] {
|
||||
return errors.New("no such rule")
|
||||
}
|
||||
delete(f.rules, port)
|
||||
return nil
|
||||
}
|
||||
func (f *fwFake) manual(port string) string { return "allow " + port }
|
||||
func (f *fwFake) open(port string) bool { ok, _, _ := f.has(port); return ok }
|
||||
|
||||
func fwFakeWorld(t *testing.T) *fwFake {
|
||||
t.Helper()
|
||||
f := &fwFake{rules: map[string]bool{}}
|
||||
prevFor, prevBy := doorFirewallFor, doorFirewallByID
|
||||
t.Cleanup(func() { doorFirewallFor, doorFirewallByID = prevFor, prevBy })
|
||||
doorFirewallFor = func(string) (doorFirewall, string) { return f, "" }
|
||||
doorFirewallByID = func(string) doorFirewall { return f }
|
||||
return f
|
||||
}
|
||||
|
||||
// The window's own door opens the way in while the window is open, and a
|
||||
// window that ends with nothing paired takes it away again.
|
||||
func TestAPairingWindowOpensTheFirewallAndClosingItRemovesTheRule(t *testing.T) {
|
||||
pairingBox(t)
|
||||
f := fwFakeWorld(t)
|
||||
addr := freeDoorAddr(t)
|
||||
port := doorPort(addr)
|
||||
_, out, res := openWindowFor(t, addr)
|
||||
if !f.open(port) || !strings.Contains(out.String(), "added its own rule in ufw") {
|
||||
t.Fatalf("the window is open and the firewall is not: %v\n%s", f.rules, out.String())
|
||||
}
|
||||
w := loadOpenWindow()
|
||||
removeWindow(w.ID) // closed at the terminal, as Ctrl-C does
|
||||
select {
|
||||
case <-res:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("pair --open did not end")
|
||||
}
|
||||
if f.open(port) {
|
||||
t.Fatal("the rule outlived a window that paired nothing")
|
||||
}
|
||||
a := pairingAuditText(t)
|
||||
if !strings.Contains(a, `"door_firewall_opened"`) || !strings.Contains(a, `"door_firewall_closed"`) {
|
||||
t.Fatalf("the audit:\n%s", a)
|
||||
}
|
||||
// MUTATION: pair --open does not release. RESULT: red, the rule outlived the window.
|
||||
}
|
||||
|
||||
// A pairing made through the window keeps the rule; the daemon's door keeps
|
||||
// it while it serves the pairing and when the bastion stops (the pairing has
|
||||
// not gone); unpairing removes it.
|
||||
func TestThePairingsDoorKeepsTheRuleUntilThePairingGoes(t *testing.T) {
|
||||
pairingBox(t)
|
||||
laneTestProcess(t)
|
||||
// Opened without machine authority, so the Host is not put in charge
|
||||
// (anchorAfterWindow) and `unpair` below may remove it: which Host is in
|
||||
// charge is TestAServerPairedThroughTheWindowHasThatHostInCharge's.
|
||||
prevAuth := machineAuthorityHeld
|
||||
machineAuthorityHeld = func() bool { return false }
|
||||
t.Cleanup(func() { machineAuthorityHeld = prevAuth })
|
||||
f := fwFakeWorld(t)
|
||||
addr := freeDoorAddr(t)
|
||||
port := doorPort(addr)
|
||||
code, _, res := openWindowFor(t, addr)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
h := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
|
||||
if done, err := h.pair(formatWindowCode(code)); err != nil || done.State != pairingActive {
|
||||
t.Fatalf("pair: %+v %v", done, err)
|
||||
}
|
||||
if err := <-res; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.open(port) {
|
||||
t.Fatal("the window's end removed the rule its active pairing needs")
|
||||
}
|
||||
cfgs, _ := loadPairings()
|
||||
cfg := pairingFor(cfgs, "home")
|
||||
// Its local control on a free port: the machine running the suite may
|
||||
// have its own bastion on the default one.
|
||||
cfg.ListenAddr = freeDoorAddr(t)
|
||||
stop := runPairingBG(t, cfg)
|
||||
doorWaitFor(t, 5*time.Second, "the door to bind", func() bool {
|
||||
c, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
}
|
||||
return err == nil
|
||||
})
|
||||
if err := stop(); err != nil && !errors.Is(err, errPairingNotActive) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.open(port) {
|
||||
t.Fatal("the bastion stopping removed the rule of a pairing that is still there")
|
||||
}
|
||||
if f.adds != 1 {
|
||||
t.Fatalf("%d adds", f.adds)
|
||||
}
|
||||
if err := cmdUnpair("home"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.open(port) {
|
||||
t.Fatal("the rule outlived the last pairing")
|
||||
}
|
||||
// MUTATION: cmdUnpair does not release. RESULT: red, the rule outlived the last pairing.
|
||||
// MUTATION: doorPortWanted ignores pairings. RESULT: red, the window's end removed the rule.
|
||||
}
|
||||
|
||||
// The daemon's door alone: bound, it opens the way in; its last pairing
|
||||
// stopped and gone, the rule goes with it.
|
||||
func TestTheDaemonsDoorRemovesTheRuleWhenItsLastPairingGoes(t *testing.T) {
|
||||
pairingBox(t)
|
||||
laneTestProcess(t)
|
||||
f := fwFakeWorld(t)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
addr := freeDoorAddr(t)
|
||||
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr) // never saved: gone once it stops
|
||||
stop := runPairingBG(t, cfg)
|
||||
doorWaitFor(t, 5*time.Second, "the rule", func() bool { return f.open(doorPort(addr)) })
|
||||
if err := stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doorWaitFor(t, 5*time.Second, "the rule to go", func() bool { return !f.open(doorPort(addr)) })
|
||||
// MUTATION: the door's release does not call doorFirewallRelease. RESULT: red, timed out waiting for the rule to go.
|
||||
// MUTATION: bindLoop does not ensure. RESULT: red, timed out waiting for the rule.
|
||||
}
|
||||
|
||||
// fwHung is a firewall whose commands hang until let go.
|
||||
type fwHung struct {
|
||||
*fwFake
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
func (f *fwHung) has(port string) (bool, bool, error) { <-f.gate; return f.fwFake.has(port) }
|
||||
|
||||
// A firewall command that hangs never holds the daemon's door shut: the door
|
||||
// serves while the bastion waits on the firewall.
|
||||
func TestTheDoorServesWhileTheFirewallHangs(t *testing.T) {
|
||||
pairingBox(t)
|
||||
laneTestProcess(t)
|
||||
f := &fwHung{fwFake: &fwFake{rules: map[string]bool{}}, gate: make(chan struct{})}
|
||||
prevFor, prevBy := doorFirewallFor, doorFirewallByID
|
||||
t.Cleanup(func() { doorFirewallFor, doorFirewallByID = prevFor, prevBy })
|
||||
doorFirewallFor = func(string) (doorFirewall, string) { return f, "" }
|
||||
doorFirewallByID = func(string) doorFirewall { return f }
|
||||
var once sync.Once
|
||||
release := func() { once.Do(func() { close(f.gate) }) }
|
||||
t.Cleanup(release)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
addr := freeDoorAddr(t)
|
||||
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
|
||||
stop := runPairingBG(t, cfg)
|
||||
doorWaitFor(t, 5*time.Second, "the door to answer a handshake", func() bool {
|
||||
c, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(time.Second))
|
||||
err = tls.Client(c, &tls.Config{InsecureSkipVerify: true, ServerName: tcpServerName}).Handshake()
|
||||
var ne net.Error
|
||||
return err == nil || !(errors.As(err, &ne) && ne.Timeout())
|
||||
})
|
||||
release()
|
||||
doorWaitFor(t, 5*time.Second, "the rule", func() bool { return f.open(doorPort(addr)) })
|
||||
if err := stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// MUTATION: bindLoop ensures before it serves. RESULT: red, timed out waiting for the door to answer a handshake.
|
||||
}
|
||||
@@ -114,11 +114,7 @@ func (c *wsConn) SetWriteDeadline(t time.Time) error { return c.ws.SetWriteDeadl
|
||||
func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls.Config,
|
||||
dialHost func(ctx context.Context) (net.Conn, error), bearer string,
|
||||
dialOut egressDialer, n int) (wait func()) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
|
||||
+8
-1
@@ -31,4 +31,11 @@ var agentTokenEnvNames = []string{"PRIVATAE_AGENT_TOKEN", "SKEMA_AGENT_TOKEN"}
|
||||
// written.
|
||||
func piiTarget() string { return os.Getenv("PRIVATAE_PII_ADDR") }
|
||||
|
||||
func llmTarget() string { return os.Getenv("PRIVATAE_LLM_ADDR") }
|
||||
// llmTarget is PRIVATAE_LLM_ADDR, or, unset, the OpenAI-compatible server
|
||||
// found on this machine's loopback (llmlocal.go), "" when there is none.
|
||||
func llmTarget() string {
|
||||
if v := os.Getenv("PRIVATAE_LLM_ADDR"); v != "" {
|
||||
return v
|
||||
}
|
||||
return discoveredLLM().Addr
|
||||
}
|
||||
|
||||
@@ -41,11 +41,7 @@ const gitForgeHost = "git.privatae.ai"
|
||||
// runGitLane serves the loopback git proxy: one host WebSocket per local connection.
|
||||
func runGitLane(ln net.Listener, wsURL string, tlsCfg *tls.Config,
|
||||
dialHost func(ctx context.Context) (net.Conn, error), bearer string) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
for {
|
||||
local, err := ln.Accept()
|
||||
if err != nil {
|
||||
|
||||
@@ -107,11 +107,7 @@ type harnessSpec struct {
|
||||
// instead of re-dialing a closed tunnel for the life of the process.
|
||||
func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string, tlsCfg *tls.Config,
|
||||
dialHost func(ctx context.Context) (net.Conn, error), bearer string, n int) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
hold := laneHoldFor(self)
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
package main
|
||||
|
||||
// hostdoor.go: the door a Host connects to when this server can't reach it.
|
||||
//
|
||||
// A Host on a person's own computer sits behind their home router: a server
|
||||
// on the internet can't open a connection to it, but it can open one to the
|
||||
// server. So for such a pairing (Config.HostDials) the Host opens the TCP
|
||||
// connections, to this server's door, and the lanes run over them exactly as
|
||||
// they run over a connection this bastion dialled to a Host's lane port: this
|
||||
// bastion is the TLS client, the Host's CA is the only root, the Host's
|
||||
// certificate must name host.cseries.internal, and this bastion presents its
|
||||
// operator certificate. Only the TCP direction is the other way round.
|
||||
//
|
||||
// One port serves every such pairing, and the Host's certificate says which
|
||||
// pairing a connection is for: the door does the TLS handshake itself, as the
|
||||
// client, and a connection whose Host certificate does not verify under the
|
||||
// pinned CA of exactly one pairing it serves is closed before a byte of a lane
|
||||
// crosses it. What passes is held, idle, in that pairing's pool, and a lane
|
||||
// that would have dialled takes one from there instead (hostDoorDial). The TLS
|
||||
// is already done, so the lane runs on it as it is (lanedial.go).
|
||||
//
|
||||
// The door is bound only while a pairing of this kind is served here (or the
|
||||
// person has a pairing window open on it, pair_window.go). It never dials,
|
||||
// never forwards a byte anywhere (a connection it keeps is a lane of a pairing
|
||||
// it already serves, and nothing else is ever read from it), and the only
|
||||
// thing it answers before a Host has proved itself is the TLS handshake. The
|
||||
// Host keeps a few idle connections open and opens another each time one is
|
||||
// taken; when it goes away, its connections close and it opens new ones when
|
||||
// it is back. This server never reconnects to it: it can't.
|
||||
//
|
||||
// The wire contract the Host implements is docs/host-connects.md.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// doorDefaultAddr is where the door listens: every address of the
|
||||
// machine, IPv4 and IPv6, port 8812 (beside the Host's own 8810/8811).
|
||||
doorDefaultAddr = ":8812"
|
||||
// The two protocols a Host can name in the handshake (ALPN): a lane
|
||||
// connection of a pairing this server serves, or a pairing through an
|
||||
// open window. The Host chooses; the door offers the second only while a
|
||||
// window is open.
|
||||
laneALPN = "privatae-lane/1"
|
||||
pairALPN = "privatae-pair/1"
|
||||
// A connection has this long to finish its TLS handshake.
|
||||
doorHandshakeTimeout = 10 * time.Second
|
||||
// At most this many handshakes are in progress at once, and this many
|
||||
// from one address (an IPv6 address counts as its /64, which one machine
|
||||
// is commonly given whole): a scan or a flood costs the door a bounded
|
||||
// number of goroutines.
|
||||
doorMaxHandshakes = 256
|
||||
doorMaxHandshakesPerSource = 8
|
||||
// Of those, this many are kept for addresses a Host proved itself from
|
||||
// within doorKnownHostFor (doorRememberHost): a stranger is admitted only
|
||||
// while fewer than doorMaxHandshakes-doorReservedHandshakes are in
|
||||
// progress, so strangers from any number of addresses never hold the last
|
||||
// slots, and the person's own Host reconnects at its first try while the
|
||||
// port is flooded. The reserve holds every handshake of 8 such addresses
|
||||
// at once; one Host connects from one.
|
||||
doorReservedHandshakes = 64
|
||||
// Random early drop for strangers, as OpenSSH's MaxStartups (default
|
||||
// 10:30:100): with fewer than doorEarlyDropStart handshakes in progress a
|
||||
// stranger is always admitted (a new Host pairing, Hosts redialling after
|
||||
// this bastion restarted); from there a stranger is refused with a chance
|
||||
// that starts at doorEarlyDropRate and rises in a line to certain at the
|
||||
// strangers' limit. A flood then fills the strangers' share slowly and
|
||||
// never the reserve.
|
||||
doorEarlyDropStart = 64
|
||||
doorEarlyDropRate = 0.3
|
||||
// An address a Host proved itself from is remembered for this long after
|
||||
// its last verified handshake, in memory only (never written anywhere,
|
||||
// gone when the bastion stops), at most doorKnownHostsMax of them. A Host
|
||||
// connected keeps it fresh; this covers one away for a week (a laptop
|
||||
// shut over a holiday) whose home address did not change.
|
||||
doorKnownHostFor = 7 * 24 * time.Hour
|
||||
doorKnownHostsMax = 64
|
||||
// A pairing holds at most this many idle connections; one more is closed.
|
||||
// The Host keeps hostSpareConns idle (docs/host-connects.md), so this is
|
||||
// far above anything a well-behaved Host opens.
|
||||
doorMaxIdle = 32
|
||||
// TCP keepalive on every connection the door keeps, under the 30 s a
|
||||
// home router's NAT may forget an idle connection after.
|
||||
doorKeepAlive = 25 * time.Second
|
||||
// A refusal from one address is written to the audit once per this, and
|
||||
// at most doorRefusalAuditSources addresses are written per period: a
|
||||
// scan from many addresses must not fill the audit or this map either.
|
||||
doorRefusalAuditEvery = 10 * time.Minute
|
||||
doorRefusalAuditSources = 256
|
||||
)
|
||||
|
||||
// doorTakeWait is how long a lane waits for its Host's next connection. A
|
||||
// lane's own context can cut it shorter.
|
||||
const doorTakeWait = egressDialTimeout
|
||||
|
||||
// hostOpenedConn is a connection a Host opened to the door, its TLS done. Idle
|
||||
// in a pool, a watcher reads it, so a connection the Host or the network closed
|
||||
// leaves the pool at once; a lane that takes one stops the watcher first.
|
||||
type hostOpenedConn struct {
|
||||
*tls.Conn
|
||||
p *doorPairing
|
||||
state atomic.Int32 // connIdle, connTaken, connDead
|
||||
done chan struct{}
|
||||
werr error // the watcher's read result, set before done closes
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
const (
|
||||
connIdle int32 = iota
|
||||
connTaken
|
||||
connDead
|
||||
)
|
||||
|
||||
// Close closes the connection and counts it gone, once. It audits nothing
|
||||
// and calls nothing but the close: every Close in this program can reach it
|
||||
// as far as the static checks can tell (claims_static_test.go), so the
|
||||
// "disconnected" audit is written by the pairing's own loop (auditLoop).
|
||||
func (c *hostOpenedConn) Close() error {
|
||||
err := c.Conn.Close()
|
||||
if c.closed.CompareAndSwap(false, true) {
|
||||
c.p.connClosed()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// watch reads the idle connection. A Host sends nothing on a connection until
|
||||
// a lane has spoken on it, so any read that returns is the end of it, unless
|
||||
// the read was the take's own stop.
|
||||
func (c *hostOpenedConn) watch() {
|
||||
var b [1]byte
|
||||
n, err := c.Conn.Read(b[:])
|
||||
if n > 0 {
|
||||
err = errors.New("the Host sent data on a connection no lane had used")
|
||||
}
|
||||
c.werr = err
|
||||
close(c.done)
|
||||
if c.state.CompareAndSwap(connIdle, connDead) {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// claim takes an idle connection for a lane: the watcher stopped, its read
|
||||
// deadline cleared. False when the connection is dead (and it is closed).
|
||||
func (c *hostOpenedConn) claim() bool {
|
||||
if !c.state.CompareAndSwap(connIdle, connTaken) {
|
||||
return false
|
||||
}
|
||||
_ = c.Conn.SetReadDeadline(time.Unix(1, 0))
|
||||
<-c.done
|
||||
var ne net.Error
|
||||
if c.werr != nil && !(errors.As(c.werr, &ne) && ne.Timeout()) {
|
||||
_ = c.Close()
|
||||
return false
|
||||
}
|
||||
_ = c.Conn.SetReadDeadline(time.Time{})
|
||||
return true
|
||||
}
|
||||
|
||||
// doorPairing is one pairing the door serves: its pinned CA, the operator
|
||||
// certificate it presents, and its idle connections.
|
||||
type doorPairing struct {
|
||||
host string
|
||||
roots *x509.CertPool
|
||||
cert tls.Certificate
|
||||
idle chan *hostOpenedConn
|
||||
gone chan struct{} // the last connection closed (auditLoop)
|
||||
live atomic.Int32 // connections open now, idle or taken
|
||||
from atomic.Value // string: the address the last connection came from
|
||||
|
||||
// mu orders put against drain: once stopped, no connection is held.
|
||||
mu sync.Mutex
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (p *doorPairing) connClosed() {
|
||||
if p.live.Add(-1) == 0 {
|
||||
select {
|
||||
case p.gone <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// auditLoop writes "disconnected" each time the pairing's last connection
|
||||
// closes, until quit. Off Close's path on purpose (hostOpenedConn.Close).
|
||||
func (p *doorPairing) auditLoop(quit <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case <-p.gone:
|
||||
if p.live.Load() == 0 {
|
||||
from, _ := p.from.Load().(string)
|
||||
pairingAudit("host_disconnected", p.host, "its last connection to this server closed (from "+from+")")
|
||||
}
|
||||
case <-quit:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// put holds a verified connection for the pairing's lanes.
|
||||
func (p *doorPairing) put(tc *tls.Conn, src string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.stopped {
|
||||
// The pairing stopped while this connection was in its handshake.
|
||||
_ = tc.Close()
|
||||
return
|
||||
}
|
||||
c := &hostOpenedConn{Conn: tc, p: p, done: make(chan struct{})}
|
||||
p.from.Store(src)
|
||||
if p.live.Add(1) == 1 {
|
||||
pairingAudit("host_connected", p.host, "connected to this server from "+src)
|
||||
// Back from away: it hears from this server now, not on the minute.
|
||||
defer kickBeats()
|
||||
}
|
||||
select {
|
||||
case p.idle <- c:
|
||||
go c.watch()
|
||||
default:
|
||||
// More idle connections than any Host keeps: this one is closed.
|
||||
c.state.Store(connDead)
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// take is a lane's dial: the next idle connection the Host opened, waiting at
|
||||
// most doorTakeWait for one.
|
||||
func (p *doorPairing) take(ctx context.Context) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, doorTakeWait)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case c := <-p.idle:
|
||||
if c.claim() {
|
||||
return c, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("%s has not connected to this server within %s (your Host connects to this server; "+
|
||||
"it does when it is running and can reach it)", p.host, doorTakeWait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain closes every idle connection: the pairing stopped.
|
||||
func (p *doorPairing) drain() {
|
||||
p.mu.Lock()
|
||||
p.stopped = true
|
||||
p.mu.Unlock()
|
||||
for {
|
||||
select {
|
||||
case c := <-p.idle:
|
||||
if c.state.CompareAndSwap(connIdle, connDead) {
|
||||
_ = c.Close()
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hostDoor is one listening door.
|
||||
type hostDoor struct {
|
||||
addr string
|
||||
// lanes: this door serves pairings' lanes (the running bastion's door).
|
||||
// A pairing window opened with no bastion holding the port is served by
|
||||
// a door of its own that serves the window alone.
|
||||
lanes bool
|
||||
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
pairings map[string]*doorPairing
|
||||
refs int
|
||||
inflight int
|
||||
perSource map[string]int
|
||||
refused map[string]time.Time
|
||||
win windowState
|
||||
closed bool
|
||||
// draw is the early drop's random number in [0, 1) (doorDraw).
|
||||
draw func() float64
|
||||
}
|
||||
|
||||
// doorDraw is where a new door's early drop draws from. A variable so a test
|
||||
// can fix it; nothing else assigns it.
|
||||
var doorDraw = rand.Float64
|
||||
|
||||
func newHostDoor(addr string, lanes bool) *hostDoor {
|
||||
return &hostDoor{addr: addr, lanes: lanes, pairings: map[string]*doorPairing{},
|
||||
perSource: map[string]int{}, refused: map[string]time.Time{}, draw: doorDraw}
|
||||
}
|
||||
|
||||
// listenDoor binds addr with keepalive on every accepted connection.
|
||||
func listenDoor(addr string) (net.Listener, error) {
|
||||
lc := net.ListenConfig{KeepAlive: doorKeepAlive}
|
||||
return lc.Listen(context.Background(), "tcp", addr)
|
||||
}
|
||||
|
||||
// serve accepts until the listener closes.
|
||||
func (d *hostDoor) serve(ln net.Listener) {
|
||||
for {
|
||||
raw, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
src := remoteHost(raw.RemoteAddr())
|
||||
if !d.admitHandshake(sourceKey(src)) {
|
||||
_ = raw.Close()
|
||||
continue
|
||||
}
|
||||
go d.handle(raw, src)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteHost(a net.Addr) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
h, _, err := net.SplitHostPort(a.String())
|
||||
if err != nil {
|
||||
return a.String()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// sourceKey is the address the per-source limits count: an IPv4 address as
|
||||
// it is, an IPv6 address as its /64.
|
||||
func sourceKey(h string) string {
|
||||
ip := net.ParseIP(h)
|
||||
if ip == nil || ip.To4() != nil {
|
||||
return h
|
||||
}
|
||||
return (&net.IPNet{IP: ip.Mask(net.CIDRMask(64, 128)), Mask: net.CIDRMask(64, 128)}).String()
|
||||
}
|
||||
|
||||
// admitHandshake takes a handshake slot for src (a sourceKey), or refuses:
|
||||
// the global and per-source limits for everyone, and for a source no Host
|
||||
// proved itself from lately, the strangers' share and its early drop.
|
||||
func (d *hostDoor) admitHandshake(src string) bool {
|
||||
known := doorKnownHost(src, time.Now())
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.inflight >= doorMaxHandshakes || d.perSource[src] >= doorMaxHandshakesPerSource {
|
||||
return false
|
||||
}
|
||||
if !known && !d.admitStranger(d.inflight) {
|
||||
return false
|
||||
}
|
||||
d.inflight++
|
||||
d.perSource[src]++
|
||||
return true
|
||||
}
|
||||
|
||||
// admitStranger is the early drop for a stranger's handshake with n in
|
||||
// progress. Called with d.mu held.
|
||||
func (d *hostDoor) admitStranger(n int) bool {
|
||||
full := doorMaxHandshakes - doorReservedHandshakes
|
||||
switch {
|
||||
case n < doorEarlyDropStart:
|
||||
return true
|
||||
case n >= full:
|
||||
return false
|
||||
}
|
||||
drop := doorEarlyDropRate + (1-doorEarlyDropRate)*float64(n-doorEarlyDropStart)/float64(full-doorEarlyDropStart)
|
||||
return d.draw() >= drop
|
||||
}
|
||||
|
||||
// doorKnown is the addresses (sourceKeys) a Host proved itself from, each
|
||||
// with when it is forgotten. In memory only: an address is never written to
|
||||
// disk, and a bastion that restarts knows none until its Hosts connect again.
|
||||
var doorKnown struct {
|
||||
sync.Mutex
|
||||
until map[string]time.Time
|
||||
}
|
||||
|
||||
// doorRememberHost notes that a Host proved itself from src (a sourceKey):
|
||||
// its certificate verified under a pairing's pinned CA, or it paired through
|
||||
// the window with the code.
|
||||
func doorRememberHost(src string, now time.Time) {
|
||||
doorKnown.Lock()
|
||||
defer doorKnown.Unlock()
|
||||
if doorKnown.until == nil {
|
||||
doorKnown.until = map[string]time.Time{}
|
||||
}
|
||||
if _, ok := doorKnown.until[src]; !ok && len(doorKnown.until) >= doorKnownHostsMax {
|
||||
oldest := ""
|
||||
for k, t := range doorKnown.until {
|
||||
if !now.Before(t) {
|
||||
delete(doorKnown.until, k)
|
||||
continue
|
||||
}
|
||||
if oldest == "" || t.Before(doorKnown.until[oldest]) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
if len(doorKnown.until) >= doorKnownHostsMax {
|
||||
delete(doorKnown.until, oldest)
|
||||
}
|
||||
}
|
||||
doorKnown.until[src] = now.Add(doorKnownHostFor)
|
||||
}
|
||||
|
||||
// doorKnownHost is whether a Host proved itself from src within
|
||||
// doorKnownHostFor.
|
||||
func doorKnownHost(src string, now time.Time) bool {
|
||||
doorKnown.Lock()
|
||||
defer doorKnown.Unlock()
|
||||
t, ok := doorKnown.until[src]
|
||||
if ok && !now.Before(t) {
|
||||
delete(doorKnown.until, src)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d *hostDoor) handshakeDone(src string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.inflight--
|
||||
if d.perSource[src]--; d.perSource[src] <= 0 {
|
||||
delete(d.perSource, src)
|
||||
}
|
||||
}
|
||||
|
||||
// match is the pairing whose pinned CA the Host's certificate verifies under,
|
||||
// for host.cseries.internal: exactly one, or a refusal.
|
||||
func (d *hostDoor) match(chain []*x509.Certificate) (*doorPairing, error) {
|
||||
if len(chain) == 0 {
|
||||
return nil, errors.New("the connection presented no certificate")
|
||||
}
|
||||
inter := x509.NewCertPool()
|
||||
for _, c := range chain[1:] {
|
||||
inter.AddCert(c)
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
var hit *doorPairing
|
||||
for _, p := range d.pairings {
|
||||
if _, err := chain[0].Verify(x509.VerifyOptions{Roots: p.roots, Intermediates: inter, DNSName: tcpServerName}); err != nil {
|
||||
continue
|
||||
}
|
||||
if hit != nil {
|
||||
return nil, errors.New("the certificate verifies for more than one pairing here — refusing")
|
||||
}
|
||||
hit = p
|
||||
}
|
||||
if hit == nil {
|
||||
return nil, errors.New("the certificate is not that of a Host this server serves")
|
||||
}
|
||||
return hit, nil
|
||||
}
|
||||
|
||||
// handle does the TLS handshake as the client and keeps the connection only
|
||||
// for what the Host proved: a lane of the one pairing its certificate names,
|
||||
// or a pairing through the open window.
|
||||
func (d *hostDoor) handle(raw net.Conn, src string) {
|
||||
released := false
|
||||
release := func() {
|
||||
if !released {
|
||||
released = true
|
||||
d.handshakeDone(sourceKey(src))
|
||||
}
|
||||
}
|
||||
defer release()
|
||||
_ = raw.SetDeadline(time.Now().Add(doorHandshakeTimeout))
|
||||
win := d.openWindow()
|
||||
var protos []string
|
||||
if d.lanes {
|
||||
protos = append(protos, laneALPN)
|
||||
}
|
||||
if win != nil {
|
||||
protos = append(protos, pairALPN)
|
||||
}
|
||||
if len(protos) == 0 {
|
||||
_ = raw.Close()
|
||||
return
|
||||
}
|
||||
var matched *doorPairing
|
||||
var pairCA *x509.Certificate
|
||||
cfg := &tls.Config{
|
||||
ServerName: tcpServerName, MinVersion: tls.VersionTLS13, NextProtos: protos,
|
||||
// The chain is checked in VerifyConnection against the pinned CAs
|
||||
// alone (or, for a pairing, against the CA it carries, which the code
|
||||
// then vouches for): the system roots play no part.
|
||||
InsecureSkipVerify: true,
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
var err error
|
||||
switch {
|
||||
case cs.NegotiatedProtocol == laneALPN && d.lanes:
|
||||
matched, err = d.match(cs.PeerCertificates)
|
||||
case cs.NegotiatedProtocol == pairALPN && win != nil:
|
||||
pairCA, err = chainCA(cs.PeerCertificates)
|
||||
default:
|
||||
err = fmt.Errorf("the connection named no protocol this server speaks (%q)", cs.NegotiatedProtocol)
|
||||
}
|
||||
return err
|
||||
},
|
||||
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
||||
if matched != nil {
|
||||
return &matched.cert, nil
|
||||
}
|
||||
// A pairing: this bastion holds no certificate from the Host yet.
|
||||
return &tls.Certificate{}, nil
|
||||
},
|
||||
}
|
||||
tc := tls.Client(raw, cfg)
|
||||
if err := tc.Handshake(); err != nil {
|
||||
d.refuse(src, err)
|
||||
_ = raw.Close()
|
||||
return
|
||||
}
|
||||
release()
|
||||
switch tc.ConnectionState().NegotiatedProtocol {
|
||||
case laneALPN:
|
||||
_ = raw.SetDeadline(time.Time{})
|
||||
d.mu.Lock()
|
||||
live := !d.closed && d.pairings[matched.host] == matched
|
||||
d.mu.Unlock()
|
||||
if !live {
|
||||
_ = tc.Close()
|
||||
return
|
||||
}
|
||||
doorRememberHost(sourceKey(src), time.Now())
|
||||
matched.put(tc, src)
|
||||
case pairALPN:
|
||||
d.serveWindow(tc, win, pairCA, src)
|
||||
}
|
||||
}
|
||||
|
||||
// refuse audits a refused connection, once per address per
|
||||
// doorRefusalAuditEvery: a scan of the port must not fill the audit.
|
||||
func (d *hostDoor) refuse(src string, err error) {
|
||||
key := sourceKey(src)
|
||||
d.mu.Lock()
|
||||
last, seen := d.refused[key]
|
||||
now := time.Now()
|
||||
if seen && now.Sub(last) < doorRefusalAuditEvery {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(d.refused) >= doorRefusalAuditSources {
|
||||
for k, t := range d.refused {
|
||||
if now.Sub(t) >= doorRefusalAuditEvery {
|
||||
delete(d.refused, k)
|
||||
}
|
||||
}
|
||||
if len(d.refused) >= doorRefusalAuditSources {
|
||||
// This period's share of the audit is spent.
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
d.refused[key] = now
|
||||
d.mu.Unlock()
|
||||
pairingAudit("door_refused", src, "refused: "+err.Error())
|
||||
}
|
||||
|
||||
// chainCA is the CA a pairing Host's chain carries, checked to sign its leaf
|
||||
// for host.cseries.internal. The code proves the Host holds it
|
||||
// (pair_window.go); nothing else does.
|
||||
func chainCA(chain []*x509.Certificate) (*x509.Certificate, error) {
|
||||
if len(chain) < 2 {
|
||||
return nil, errors.New("the Host presented no certificate authority with its certificate")
|
||||
}
|
||||
for _, c := range chain[1:] {
|
||||
if !c.IsCA {
|
||||
continue
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(c)
|
||||
if _, err := chain[0].Verify(x509.VerifyOptions{Roots: roots, DNSName: tcpServerName}); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("the Host's certificate is not signed by a certificate authority it presented, for " + tcpServerName)
|
||||
}
|
||||
|
||||
// ── the running bastion's doors ─────────────────────────────────────────────
|
||||
|
||||
var doors struct {
|
||||
sync.Mutex
|
||||
m map[string]*hostDoor
|
||||
}
|
||||
|
||||
// doorBindRetry is how often a door that could not bind tries again (the
|
||||
// port is held by a pairing window's own door until that window closes).
|
||||
var doorBindRetry = 2 * time.Second
|
||||
|
||||
// newDoorPairing is cfg as the door serves it: its pinned CA, its operator
|
||||
// certificate, an empty pool.
|
||||
func newDoorPairing(cfg *Config) (*doorPairing, error) {
|
||||
cert, err := tls.X509KeyPair([]byte(cfg.OperatorCert), []byte(cfg.OperatorKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("operator cert/key: %w", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM([]byte(cfg.CACert)) {
|
||||
return nil, errors.New("bad CA cert in config")
|
||||
}
|
||||
return &doorPairing{host: cfg.HostName, roots: roots, cert: cert, idle: make(chan *hostOpenedConn, doorMaxIdle),
|
||||
gone: make(chan struct{}, 1)}, nil
|
||||
}
|
||||
|
||||
// doorOpen registers an active host-dials pairing with the door on its
|
||||
// address, binding the door if this is its first pairing. The returned
|
||||
// release unregisters it and closes the door after its last pairing.
|
||||
func doorOpen(cfg *Config) (func(), error) {
|
||||
p, err := newDoorPairing(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr := cfg.doorAddr()
|
||||
quit := make(chan struct{})
|
||||
go p.auditLoop(quit)
|
||||
doors.Lock()
|
||||
if doors.m == nil {
|
||||
doors.m = map[string]*hostDoor{}
|
||||
}
|
||||
d := doors.m[addr]
|
||||
first := d == nil
|
||||
if first {
|
||||
d = newHostDoor(addr, true)
|
||||
doors.m[addr] = d
|
||||
}
|
||||
d.mu.Lock()
|
||||
if _, dup := d.pairings[cfg.HostName]; dup {
|
||||
d.mu.Unlock()
|
||||
doors.Unlock()
|
||||
close(quit)
|
||||
return nil, fmt.Errorf("%s is already served on %s", cfg.HostName, addr)
|
||||
}
|
||||
d.pairings[cfg.HostName] = p
|
||||
d.refs++
|
||||
d.mu.Unlock()
|
||||
doors.Unlock()
|
||||
if first {
|
||||
go d.bindLoop()
|
||||
}
|
||||
return func() {
|
||||
defer close(quit)
|
||||
doors.Lock()
|
||||
d.mu.Lock()
|
||||
if d.pairings[cfg.HostName] == p {
|
||||
delete(d.pairings, cfg.HostName)
|
||||
}
|
||||
d.refs--
|
||||
last := d.refs == 0
|
||||
if last {
|
||||
d.closed = true
|
||||
delete(doors.m, addr)
|
||||
}
|
||||
ln := d.ln
|
||||
d.mu.Unlock()
|
||||
doors.Unlock()
|
||||
p.drain()
|
||||
if last && ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
if last {
|
||||
// The rule stays while a pairing on this port is still active
|
||||
// (the bastion stopping is not the pairing going) or a window
|
||||
// is open on it; otherwise it goes now.
|
||||
doorFirewallRelease(addr)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bindLoop binds the door and serves it, trying again while the port is held
|
||||
// and the door still has a pairing.
|
||||
func (d *hostDoor) bindLoop() {
|
||||
logged := false
|
||||
for {
|
||||
d.mu.Lock()
|
||||
if d.closed {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.mu.Unlock()
|
||||
ln, err := listenDoor(d.addr)
|
||||
if err == nil {
|
||||
d.mu.Lock()
|
||||
if d.closed {
|
||||
d.mu.Unlock()
|
||||
_ = ln.Close()
|
||||
return
|
||||
}
|
||||
d.ln = ln
|
||||
d.mu.Unlock()
|
||||
fmt.Printf("door: your Host connects to this server on %s (TCP)\n", d.addr)
|
||||
// The way in through this machine's own firewall, while the door
|
||||
// is in use (doorfw.go); removed when its last pairing goes. Beside
|
||||
// serving, never before it: a firewall command that hangs (each
|
||||
// may take doorFWExecWait) never holds the door shut.
|
||||
go func() {
|
||||
still := func() bool { d.mu.Lock(); defer d.mu.Unlock(); return !d.closed }
|
||||
if said := doorFirewallEnsureWhile(d.addr, still); said != "" {
|
||||
fmt.Println("door: " + said)
|
||||
}
|
||||
}()
|
||||
d.serve(ln)
|
||||
return
|
||||
}
|
||||
if !logged {
|
||||
fmt.Printf("door: cannot listen on %s yet (%v); trying again every %s\n", d.addr, err, doorBindRetry)
|
||||
logged = true
|
||||
}
|
||||
time.Sleep(doorBindRetry)
|
||||
}
|
||||
}
|
||||
|
||||
// hostDoorDial is a host-dials pairing's lane dial: the next connection its
|
||||
// Host opened to the door.
|
||||
func hostDoorDial(cfg *Config) func(ctx context.Context) (net.Conn, error) {
|
||||
addr, host := cfg.doorAddr(), cfg.HostName
|
||||
return func(ctx context.Context) (net.Conn, error) {
|
||||
doors.Lock()
|
||||
d := doors.m[addr]
|
||||
doors.Unlock()
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no door is open on %s for %s", addr, host)
|
||||
}
|
||||
d.mu.Lock()
|
||||
p := d.pairings[host]
|
||||
d.mu.Unlock()
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("%s is not served on %s", host, addr)
|
||||
}
|
||||
return p.take(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// hostDialsLaneDialer is localLaneDialer for a pairing whose Host connects
|
||||
// to this server. The TLS configuration is the one the door uses, kept for
|
||||
// the lanes' own use; every connection the dial returns has it done.
|
||||
func hostDialsLaneDialer(cfg *Config) (func(ctx context.Context) (net.Conn, error), *tls.Config, error) {
|
||||
if cfg.Bearer == "" {
|
||||
return nil, nil, errors.New("this pairing holds no bearer, and its lane never opens without one")
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(cfg.OperatorCert), []byte(cfg.OperatorKey))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("operator cert/key: %w", err)
|
||||
}
|
||||
tc, err := hostCATLS(cfg.CACert, &cert)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("bad CA cert in config: %w", err)
|
||||
}
|
||||
return hostDoorDial(cfg), tc, nil
|
||||
}
|
||||
|
||||
// doorAddr is where this pairing's Host connects: the address it was paired
|
||||
// on, or the default.
|
||||
func (c *Config) doorAddr() string {
|
||||
if strings.TrimSpace(c.DoorAddr) != "" {
|
||||
return c.DoorAddr
|
||||
}
|
||||
return doorDefaultAddr
|
||||
}
|
||||
|
||||
// doorPort is the door's port, for the person's words.
|
||||
func doorPort(addr string) string {
|
||||
if _, p, err := net.SplitHostPort(addr); err == nil {
|
||||
return p
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// windowBox is a pairing box whose `pair --open` runs with machine authority
|
||||
// (held) and no organisation's policy, in the given mode.
|
||||
func windowBox(t *testing.T, networked, held bool) {
|
||||
t.Helper()
|
||||
pairingBox(t)
|
||||
prev := machineAuthorityHeld
|
||||
machineAuthorityHeld = func() bool { return true }
|
||||
t.Cleanup(func() { machineAuthorityHeld = prev })
|
||||
withPolicy(t, nil)
|
||||
if networked {
|
||||
if err := setBoxMode(modeNetworked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
machineAuthorityHeld = func() bool { return held }
|
||||
}
|
||||
|
||||
// pairThroughWindow opens a window on a free port, pairs h's Host through it,
|
||||
// and waits for `pair --open` to finish. h is made for that port.
|
||||
func pairThroughWindow(t *testing.T, ca *testCA, handle, name string) (windowDoneReq, string) {
|
||||
t.Helper()
|
||||
addr := freeDoorAddr(t)
|
||||
code, out, res := openWindowFor(t, addr)
|
||||
h := newStandinHost(t, ca, handle, name, addr)
|
||||
done, err := h.pair(formatWindowCode(code))
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v\n%s", err, pairingAuditText(t))
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
if err != nil {
|
||||
t.Fatalf("pair --open: %v\n%s", err, out.String())
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("pair --open never ended")
|
||||
}
|
||||
return done, out.String()
|
||||
}
|
||||
|
||||
// 1. A server paired through `pair --open`, by someone with machine
|
||||
// authority, on a machine nobody is in charge of, has that Host in charge:
|
||||
// the anchor, the existing mechanism. A local machine is made networked for
|
||||
// it (an anchor is a networked machine's). The rules before it still win: a
|
||||
// Host on this machine, an organisation's policy naming another Host. Nobody
|
||||
// without machine authority puts anyone in charge, and a local machine that
|
||||
// already serves a Host the person said yes to keeps serving it.
|
||||
//
|
||||
// MUTATION: windowCommit calls anchorAfterPair instead of anchorAfterWindow.
|
||||
// RESULT: red (local, and networked beside another pairing: no anchor).
|
||||
// MUTATION: drop the sameMachineHost check in anchorAfterWindow. RESULT: red.
|
||||
// MUTATION: drop the local-mode active check. RESULT: red.
|
||||
// MUTATION: anchorAfterWindow ignores openerAuthority. RESULT: red.
|
||||
func TestAServerPairedThroughTheWindowHasThatHostInCharge(t *testing.T) {
|
||||
const name = "home201721e4.cseries.localhost"
|
||||
t.Run("local machine, nobody in charge: networked, anchored, active", func(t *testing.T) {
|
||||
windowBox(t, false, true)
|
||||
done, out := pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if done.State != pairingActive {
|
||||
t.Fatalf("done: %+v", done)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != name {
|
||||
t.Fatalf("anchor = %+v, want %s", a, name)
|
||||
}
|
||||
if boxMode() != modeNetworked {
|
||||
t.Fatalf("mode %s: a machine with an anchor is networked", boxMode())
|
||||
}
|
||||
if !strings.Contains(out, name+" is now the Host in charge of this machine") || !strings.Contains(out, "pair --open") {
|
||||
t.Fatalf("the person is not told who is in charge:\n%s", out)
|
||||
}
|
||||
if a := pairingAuditText(t); !strings.Contains(a, `"op":"anchor_take"`) {
|
||||
t.Fatalf("no anchor_take in the audit:\n%s", a)
|
||||
}
|
||||
})
|
||||
t.Run("networked, unanchored, another pairing already there: anchored, the other waits", func(t *testing.T) {
|
||||
windowBox(t, true, true)
|
||||
writePairing(t, tRemote)
|
||||
_, _ = pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != name {
|
||||
t.Fatalf("anchor = %+v, want %s", a, name)
|
||||
}
|
||||
if got := loadState(t, name); got != pairingActive {
|
||||
t.Fatalf("the anchor never waits on itself: %q", got)
|
||||
}
|
||||
if got := loadState(t, tRemote); got != pairingPending {
|
||||
t.Fatalf("the other Host is %q, want pending (waiting for %s)", got, name)
|
||||
}
|
||||
})
|
||||
t.Run("a Host on this machine is paired: it stays the one in charge", func(t *testing.T) {
|
||||
windowBox(t, true, true)
|
||||
writeTCPPairing(t, tLocal2, "127.0.0.1:8811", pairingPending)
|
||||
_, _ = pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if a := currentAnchor(); a != nil {
|
||||
t.Fatalf("a Host on this machine lost the machine to the window's Host: anchor %+v", a)
|
||||
}
|
||||
})
|
||||
t.Run("an organisation's policy names another Host: it waits for that one", func(t *testing.T) {
|
||||
windowBox(t, true, true)
|
||||
withPolicy(t, &anchorPolicy{Host: "corp.host.privatae.ai", Source: "test policy"})
|
||||
done, _ := pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if a := currentAnchor(); a != nil {
|
||||
t.Fatalf("anchor %+v against the policy", a)
|
||||
}
|
||||
if done.State != pairingPending || done.AdmittedBy != "corp.host.privatae.ai" {
|
||||
t.Fatalf("done: %+v, want pending for the Host the policy names", done)
|
||||
}
|
||||
})
|
||||
t.Run("an organisation's policy names this Host: in charge, on a local machine too", func(t *testing.T) {
|
||||
windowBox(t, false, true)
|
||||
withPolicy(t, &anchorPolicy{Host: name, Source: "test policy"})
|
||||
writeTCPPairing(t, tLocal2, "127.0.0.1:8811", pairingRevoked)
|
||||
_, _ = pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != name {
|
||||
t.Fatalf("anchor = %+v, want %s (the policy names it)", a, name)
|
||||
}
|
||||
})
|
||||
t.Run("no machine authority: nobody is put in charge, the mode is left", func(t *testing.T) {
|
||||
windowBox(t, false, false)
|
||||
_, _ = pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if a := currentAnchor(); a != nil || boxMode() != modeLocal {
|
||||
t.Fatalf("without authority: anchor %+v, mode %s", a, boxMode())
|
||||
}
|
||||
})
|
||||
t.Run("opened without machine authority, committed by a door that has it: nobody in charge", func(t *testing.T) {
|
||||
windowBox(t, false, true)
|
||||
cfg := Config{HostName: name, State: ""}
|
||||
if took, err := anchorAfterWindow(&cfg, []*Config{&cfg}, false); took.Taken || err != nil || currentAnchor() != nil {
|
||||
t.Fatalf("a window opened without authority put %s in charge: %+v %v", name, took, err)
|
||||
}
|
||||
})
|
||||
t.Run("a local machine already serving another Host: it keeps serving it", func(t *testing.T) {
|
||||
windowBox(t, false, true)
|
||||
writePairing(t, tRemote)
|
||||
_, _ = pairThroughWindow(t, newTestCA(t, "home CA"), "home", name)
|
||||
if a := currentAnchor(); a != nil || boxMode() != modeLocal {
|
||||
t.Fatalf("anchor %+v, mode %s: the Host already served here would wait", a, boxMode())
|
||||
}
|
||||
if got := loadState(t, tRemote); got == pairingPending {
|
||||
t.Fatal("the Host already served here was left waiting")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 2. The same Host pairing the same server again, through a fresh window,
|
||||
// replaces its pairing: no `unpair` first. The same CA is the same Host,
|
||||
// whatever handle it gives (a C-Series Host's handle is "home" while its name
|
||||
// carries an id). Its state, the anchor and its grants are kept, and the old
|
||||
// credentials are gone.
|
||||
//
|
||||
// MUTATION: drop the doorPairingWithCA lookup in windowCommit. RESULT: red,
|
||||
// "this Host is already paired with this server as ...".
|
||||
func TestTheSameHostPairingAgainReplacesItsPairing(t *testing.T) {
|
||||
const name = "home201721e4.cseries.localhost"
|
||||
windowBox(t, true, true)
|
||||
ca := newTestCA(t, "home CA")
|
||||
_, _ = pairThroughWindow(t, ca, "home", name)
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != name {
|
||||
t.Fatalf("first pairing: anchor %+v", a)
|
||||
}
|
||||
before, _ := pairingNamed(t, name)
|
||||
gp, err := pairingGrantPath(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(gp, []byte(`{"kept":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done, out := pairThroughWindow(t, ca, "home", name)
|
||||
if done.State != pairingActive || done.HostName != name {
|
||||
t.Fatalf("pairing again: %+v\n%s", done, out)
|
||||
}
|
||||
after, all := pairingNamed(t, name)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("%d pairings after pairing again, want the one", len(all))
|
||||
}
|
||||
if after.OperatorCert == before.OperatorCert || after.OperatorKey == before.OperatorKey || after.WGPrivateKey == before.WGPrivateKey {
|
||||
t.Fatal("the old credentials were kept")
|
||||
}
|
||||
if after.ListenAddr != before.ListenAddr || after.FirstSeen != before.FirstSeen || after.State != before.State {
|
||||
t.Fatalf("the pairing was not kept in place: %+v -> %+v", before, after)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != name {
|
||||
t.Fatalf("the anchor was not kept: %+v", a)
|
||||
}
|
||||
if b, err := os.ReadFile(gp); err != nil || string(b) != `{"kept":true}` {
|
||||
t.Fatalf("the grant was not kept: %q %v", b, err)
|
||||
}
|
||||
|
||||
// Another Host (another CA) with the same handle is never this one.
|
||||
done, err = func() (windowDoneReq, error) {
|
||||
addr := freeDoorAddr(t)
|
||||
code, _, res := openWindowFor(t, addr)
|
||||
h := newStandinHost(t, newTestCA(t, "another CA"), "home", name, addr)
|
||||
d, err := h.pair(formatWindowCode(code))
|
||||
<-res
|
||||
return d, err
|
||||
}()
|
||||
if err != nil || done.State != "refused" || !strings.Contains(done.Error, "different Host") {
|
||||
t.Fatalf("another Host of the same name: %+v %v", done, err)
|
||||
}
|
||||
if c, _ := pairingNamed(t, name); c.Bearer != after.Bearer {
|
||||
t.Fatal("another Host's pairing replaced this one's")
|
||||
}
|
||||
}
|
||||
|
||||
// The same Host paired again under a new name (same CA) stays in charge under
|
||||
// that name: the anchor follows it, its pinned key kept.
|
||||
//
|
||||
// MUTATION: drop carryAnchorToNewName from windowCommit. RESULT: red.
|
||||
func TestTheAnchorFollowsItsHostToANewName(t *testing.T) {
|
||||
windowBox(t, true, true)
|
||||
ca := newTestCA(t, "home CA")
|
||||
_, _ = pairThroughWindow(t, ca, "home", "home1.cseries.localhost")
|
||||
pub, _, _ := ed25519.GenerateKey(nil)
|
||||
if err := pinAnchorVerifyKey(base64.StdEncoding.EncodeToString(pub)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _ := loadAnchorFile()
|
||||
pin := f.VerifyPubkey
|
||||
_, _ = pairThroughWindow(t, ca, "home", "home2.cseries.localhost")
|
||||
all, _ := loadPairings()
|
||||
if len(all) != 1 || all[0].HostName != "home2.cseries.localhost" {
|
||||
t.Fatalf("pairings: %v", hostNames(all))
|
||||
}
|
||||
f, _ = loadAnchorFile()
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != "home2.cseries.localhost" || f.VerifyPubkey != pin || pin == "" {
|
||||
t.Fatalf("anchor %+v (pin kept: %v)", a, f != nil && f.VerifyPubkey == pin)
|
||||
}
|
||||
}
|
||||
|
||||
// The anchor moves only with machine authority, as every other anchor write
|
||||
// (claim-anchor --repin's wall): a process without it leaves anchor.json as
|
||||
// it is.
|
||||
//
|
||||
// MUTATION: drop the machineAuthorityHeld check in carryAnchorToNewName.
|
||||
// RESULT: red, the anchor names the new name.
|
||||
func TestTheAnchorMovesToANewNameOnlyWithMachineAuthority(t *testing.T) {
|
||||
windowBox(t, true, true)
|
||||
ca := newTestCA(t, "home CA")
|
||||
_, _ = pairThroughWindow(t, ca, "home", "home1.cseries.localhost")
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != "home1.cseries.localhost" {
|
||||
t.Fatalf("first pairing: anchor %+v", a)
|
||||
}
|
||||
machineAuthorityHeld = func() bool { return false }
|
||||
_, _ = pairThroughWindow(t, ca, "home", "home2.cseries.localhost")
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != "home1.cseries.localhost" {
|
||||
t.Fatalf("without machine authority the anchor was rewritten: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Right after `unpair` the running bastion still holds the door's port
|
||||
// until it sees the pairing gone (a supervision period). `pair --open` waits
|
||||
// for it to let go instead of failing "address already in use".
|
||||
//
|
||||
// MUTATION: drop the awaitDoorReleased call in cmdPairOpen. RESULT: red.
|
||||
func TestPairOpenWaitsForTheRunningBastionToLetGoOfThePort(t *testing.T) {
|
||||
pairingBox(t)
|
||||
prevRunning, prevWait := pairDaemonRunning, doorReleaseWait
|
||||
pairDaemonRunning = func() bool { return true }
|
||||
doorReleaseWait = 10 * time.Second
|
||||
t.Cleanup(func() { pairDaemonRunning, doorReleaseWait = prevRunning, prevWait })
|
||||
addr := freeDoorAddr(t)
|
||||
held, err := listenDoor(addr) // the running bastion's door, its pairing just removed
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.AfterFunc(1500*time.Millisecond, func() { _ = held.Close() })
|
||||
code, out, res := openWindowFor(t, addr)
|
||||
if !strings.Contains(out.String(), "waiting for it to let go") {
|
||||
t.Fatalf("the person is not told why it waits:\n%s", out.String())
|
||||
}
|
||||
h := newStandinHost(t, newTestCA(t, "home CA"), "home", "home.cseries.localhost", addr)
|
||||
if done, err := h.pair(formatWindowCode(code)); err != nil || done.State != pairingActive {
|
||||
t.Fatalf("pair after the port was let go: %+v %v", done, err)
|
||||
}
|
||||
if err := <-res; err != nil {
|
||||
t.Fatalf("pair --open: %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
// Another program that never lets go: it still fails, after the wait, and
|
||||
// says what to do.
|
||||
doorReleaseWait = 300 * time.Millisecond
|
||||
addr2 := freeDoorAddr(t)
|
||||
other, err := net.Listen("tcp", addr2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer other.Close()
|
||||
err = cmdPairOpen(addr2, &syncBuf{})
|
||||
if err == nil || !strings.Contains(err.Error(), "choose another with --port") {
|
||||
t.Fatalf("a port another program holds: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "address already in use") {
|
||||
t.Fatalf("not refused for the address in use: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// freeDoorAddr is a loopback port nothing listens on.
|
||||
func freeDoorAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
return a
|
||||
}
|
||||
|
||||
// syncBuf is a bytes.Buffer two goroutines can share.
|
||||
type syncBuf struct {
|
||||
mu sync.Mutex
|
||||
b bytes.Buffer
|
||||
}
|
||||
|
||||
func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
|
||||
func (s *syncBuf) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
|
||||
|
||||
// openWindowFor runs `pair --open` on addr in the background and returns the
|
||||
// code it shows, its output, and its result.
|
||||
func openWindowFor(t *testing.T, addr string) (code string, out *syncBuf, result <-chan error) {
|
||||
t.Helper()
|
||||
prevPoll := windowPollEvery
|
||||
windowPollEvery = 20 * time.Millisecond
|
||||
t.Cleanup(func() { windowPollEvery = prevPoll })
|
||||
out = &syncBuf{}
|
||||
res := make(chan error, 1)
|
||||
go func() { res <- cmdPairOpen(addr, out) }()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if w := loadOpenWindow(); w != nil && strings.Contains(out.String(), formatWindowCode(w.Code)) {
|
||||
return w.Code, out, res
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("pair --open showed no code: %q", out.String())
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
func pairingAuditText(t *testing.T) string {
|
||||
t.Helper()
|
||||
d, _ := configDir()
|
||||
b, _ := os.ReadFile(filepath.Join(d, "pairing_audit.jsonl"))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// The code the person reads is the code both sides key the proofs with,
|
||||
// however it is typed back.
|
||||
func TestTheWindowCodeIsReadBackHoweverItIsTyped(t *testing.T) {
|
||||
c, err := newWindowCode()
|
||||
if err != nil || normalizeWindowCode(c) != c || len(c) != windowCodeLen {
|
||||
t.Fatalf("a fresh code is not normalised: %q %v", c, err)
|
||||
}
|
||||
f := formatWindowCode(c)
|
||||
if strings.Count(f, "-") != 3 || normalizeWindowCode(f) != c || normalizeWindowCode(strings.ToLower(f)) != c {
|
||||
t.Fatalf("the shown code %q does not read back as %q", f, c)
|
||||
}
|
||||
if normalizeWindowCode("O1IL0-00000-00000-00000") != "01110000000000000000" {
|
||||
t.Fatal("O, I and L read as the digits they look like")
|
||||
}
|
||||
for _, bad := range []string{"", "ABCDE", c + "0", strings.Replace(c, c[:1], "U", 1)} {
|
||||
if normalizeWindowCode(bad) != "" {
|
||||
t.Fatalf("%q is not a code", bad)
|
||||
}
|
||||
}
|
||||
for args, want := range map[string]string{"--open": doorDefaultAddr, "--open --port 9443": ":9443", "--port=9443 --open": ":9443"} {
|
||||
got, ok, err := parsePairOpenArgs(strings.Fields(args))
|
||||
if !ok || err != nil || got != want {
|
||||
t.Fatalf("%s: %q %v %v", args, got, ok, err)
|
||||
}
|
||||
}
|
||||
for _, args := range []string{"--open --port 0", "--open --port x", "--open skb_x"} {
|
||||
if _, ok, err := parsePairOpenArgs(strings.Fields(args)); !ok || err == nil {
|
||||
t.Fatalf("%s must be refused", args)
|
||||
}
|
||||
}
|
||||
if _, ok, _ := parsePairOpenArgs([]string{"skb_x"}); ok {
|
||||
t.Fatal("a token pair is not --open")
|
||||
}
|
||||
}
|
||||
|
||||
// A Host that connects pairs through the window: the code proved both ways,
|
||||
// the pairing saved with its CA pinned and no address to dial, the window
|
||||
// closed after one use, and each step in the audit.
|
||||
func TestAHostThatConnectsPairsThroughTheWindow(t *testing.T) {
|
||||
pairingBox(t)
|
||||
addr := freeDoorAddr(t)
|
||||
code, out, res := openWindowFor(t, addr)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
h := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
|
||||
done, err := h.pair(formatWindowCode(code))
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, pairingAuditText(t))
|
||||
}
|
||||
if done.State != pairingActive || done.HostName != "home.cseries.localhost" {
|
||||
t.Fatalf("done: %+v", done)
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
if err != nil {
|
||||
t.Fatalf("pair --open: %v\n%s", err, out.String())
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("pair --open never saw the pairing end its window")
|
||||
}
|
||||
if !strings.Contains(out.String(), "Your Host connects to this server") || !strings.Contains(out.String(), "Paired with home.cseries.localhost") {
|
||||
t.Fatalf("what the person reads:\n%s", out.String())
|
||||
}
|
||||
cfgs, _ := loadPairings()
|
||||
c := pairingFor(cfgs, "home")
|
||||
if c == nil || !c.hostDials() || c.DoorAddr != addr || c.LaneAddr != "" || !sameCAs(c.CACert, ca.pem) ||
|
||||
!strings.HasPrefix(c.Bearer, "mcp_dials_home_") || operatorCertRefusal(c.OperatorCert, ca.pem, c.OperatorKey) != nil {
|
||||
t.Fatalf("the pairing: %+v", c)
|
||||
}
|
||||
if w := readWindowFile(); w != nil {
|
||||
t.Fatalf("the window outlived its pairing: %+v", w)
|
||||
}
|
||||
// One use: the same code again finds no window.
|
||||
if _, err := h.pair(code); err == nil {
|
||||
t.Fatal("a used code paired again")
|
||||
}
|
||||
a := pairingAuditText(t)
|
||||
for _, op := range []string{`"window_opened"`, `"window_paired"`} {
|
||||
if !strings.Contains(a, op) {
|
||||
t.Fatalf("the audit has no %s:\n%s", op, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: a wrong code gets nothing, costs a wait that doubles, and the
|
||||
// fifth closes the window. A proof made over another certificate (one
|
||||
// relayed from another connection) is a wrong code. A Host never signs for a
|
||||
// server that did not prove the code.
|
||||
func TestAWrongCodeIsThrottledAndClosesTheWindow(t *testing.T) {
|
||||
pairingBox(t)
|
||||
prev := windowFailWait
|
||||
windowFailWait = 100 * time.Millisecond
|
||||
t.Cleanup(func() { windowFailWait = prev })
|
||||
addr := freeDoorAddr(t)
|
||||
code, _, res := openWindowFor(t, addr)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
h := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
|
||||
|
||||
relayed := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
|
||||
relayed.lieLeaf = true
|
||||
if _, err := relayed.pair(code); err == nil {
|
||||
t.Fatal("a proof over another certificate paired")
|
||||
}
|
||||
// The wait after it: the next try, at once, is not even offered a pairing.
|
||||
if _, err := h.pair(code); err == nil || !strings.Contains(err.Error(), "handshake") {
|
||||
t.Fatalf("a try inside the wait must be refused at the handshake, got %v", err)
|
||||
}
|
||||
wrong := "ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ"
|
||||
for i := 2; i <= windowMaxFailures; i++ {
|
||||
time.Sleep(time.Duration(1<<(i-2))*windowFailWait + 50*time.Millisecond)
|
||||
if _, err := h.pair(wrong); err == nil {
|
||||
t.Fatalf("wrong code %d paired", i)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
if err == nil || !strings.Contains(err.Error(), "wrong codes") {
|
||||
t.Fatalf("pair --open after %d wrong codes: %v", windowMaxFailures, err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the window stayed open after five wrong codes")
|
||||
}
|
||||
if _, err := h.pair(code); err == nil {
|
||||
t.Fatal("the right code paired after the window closed")
|
||||
}
|
||||
cfgs, _ := loadPairings()
|
||||
if len(cfgs) != 0 || h.csrs != 0 || relayed.csrs != 0 {
|
||||
t.Fatalf("a wrong code got something: %d pairings, %d CSRs signed", len(cfgs), h.csrs+relayed.csrs)
|
||||
}
|
||||
if n := strings.Count(pairingAuditText(t), `"window_wrong_code"`); n != windowMaxFailures {
|
||||
t.Fatalf("%d wrong codes audited, want %d", n, windowMaxFailures)
|
||||
}
|
||||
|
||||
// The Host's side: a server whose proof does not check gets no signature.
|
||||
addr2 := freeDoorAddr(t)
|
||||
code2, _, res2 := openWindowFor(t, addr2)
|
||||
strict := newStandinHost(t, ca, "home", "home.cseries.localhost", addr2)
|
||||
strict.badProof = true
|
||||
if d, err := strict.pair(code2); err != nil || d.State != "refused" {
|
||||
t.Fatalf("a Host refusing the server's proof: %+v %v", d, err)
|
||||
}
|
||||
if err := <-res2; err == nil {
|
||||
t.Fatal("the window reported a pairing the Host refused")
|
||||
}
|
||||
if cfgs, _ := loadPairings(); len(cfgs) != 0 || strict.csrs != 0 {
|
||||
t.Fatal("a pairing was kept that the Host never signed for")
|
||||
}
|
||||
}
|
||||
|
||||
// hostDialsPairingFor is a host-dials pairing with an operator certificate
|
||||
// from ca and its door on addr.
|
||||
func hostDialsPairingFor(t *testing.T, ca *testCA, host, addr string) *Config {
|
||||
t.Helper()
|
||||
c := tcpPairingFor(t, ca, host, "")
|
||||
c.HostDials, c.DoorAddr = true, addr
|
||||
c.Bearer = "mcp_dials_lane_bearer"
|
||||
return c
|
||||
}
|
||||
|
||||
// runPairingBG runs the pairing until the returned stop.
|
||||
func runPairingBG(t *testing.T, cfg *Config) (stop func() error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- runPairing(ctx, cfg) }()
|
||||
return func() error {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the pairing did not stop")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func doorWaitFor(t *testing.T, d time.Duration, what string, ok func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if ok() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// The lanes run over the connections the Host opens, exactly as over a
|
||||
// dialled lane port: the heartbeat with the bearer, every worker pool, a
|
||||
// harness job on a parked worker, every handshake under host.cseries.internal
|
||||
// and with the operator certificate (the stand-in requires and verifies it).
|
||||
// The door is bound only while the pairing is served, and the bastion never
|
||||
// dials anything.
|
||||
func TestTheLanesRunOverTheConnectionsTheHostOpens(t *testing.T) {
|
||||
pairingBox(t)
|
||||
laneTestProcess(t)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
addr := freeDoorAddr(t)
|
||||
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
|
||||
h := newStandinHost(t, ca, "home", cfg.HostName, addr)
|
||||
h.lane.jobs <- []byte(`{"harness_id":"h1","mode":"echo","prompt":"hello over a Host-opened connection"}`)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go h.serveLanes(ctx)
|
||||
|
||||
stop := runPairingBG(t, cfg)
|
||||
doorWaitFor(t, 10*time.Second, "the heartbeat and every worker", func() bool {
|
||||
hellos, beats := h.lane.snapshot()
|
||||
return len(beats) > 0 && len(hellos) >= harnessWorkerCount+sessionWorkerCount+mgmtWorkerCount+egressWorkerCount+serveWorkerCount
|
||||
})
|
||||
select {
|
||||
case r := <-h.lane.replies:
|
||||
if !strings.HasSuffix(r, "exit=0") {
|
||||
t.Fatalf("the harness job: %s", r)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("a parked harness worker served no job")
|
||||
}
|
||||
hellos, beats := h.lane.snapshot()
|
||||
if beats[0] != "Bearer "+cfg.Bearer {
|
||||
t.Fatalf("the heartbeat carries the bearer: %q", beats)
|
||||
}
|
||||
count := map[string]int{}
|
||||
for _, hl := range hellos {
|
||||
path, msg, _ := strings.Cut(hl, " ")
|
||||
if !strings.HasPrefix(msg, "BEARER "+cfg.Bearer) {
|
||||
t.Fatalf("a worker opened without the bearer: %q", hl)
|
||||
}
|
||||
if strings.Contains(msg, "lane=session") {
|
||||
path += " session"
|
||||
}
|
||||
count[path]++
|
||||
}
|
||||
if count["/bastion-harness"] < harnessWorkerCount || count["/bastion-harness session"] != sessionWorkerCount ||
|
||||
count["/bastion-mgmt"] != mgmtWorkerCount || count["/bastion-egress"] != egressWorkerCount ||
|
||||
count["/bastion-serve"] != serveWorkerCount {
|
||||
t.Fatalf("the worker pools over Host-opened connections: %v", count)
|
||||
}
|
||||
h.lane.mu.Lock()
|
||||
sni := append([]string(nil), h.lane.sni...)
|
||||
h.lane.mu.Unlock()
|
||||
for _, n := range sni {
|
||||
if n != tcpServerName {
|
||||
t.Fatalf("a handshake asked for %q", n)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(pairingAuditText(t), `"host_connected"`) {
|
||||
t.Fatal("the Host's connection is not in the audit")
|
||||
}
|
||||
if err := stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Closed with its last pairing.
|
||||
doorWaitFor(t, 5*time.Second, "the door to close", func() bool {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err == nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
return err == nil
|
||||
})
|
||||
}
|
||||
|
||||
// SECURITY: the door keeps a connection only from the Host whose pinned CA
|
||||
// its certificate verifies under, for host.cseries.internal, naming the lane
|
||||
// protocol. Another CA, the right CA under another name, no protocol, and a
|
||||
// pairing protocol with no window open all end at the handshake: nothing is
|
||||
// pooled and no lane ever runs on them.
|
||||
func TestTheDoorRefusesAnythingButItsPinnedHost(t *testing.T) {
|
||||
pairingBox(t)
|
||||
laneTestProcess(t)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
addr := freeDoorAddr(t)
|
||||
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
|
||||
stop := runPairingBG(t, cfg)
|
||||
defer stop()
|
||||
doorWaitFor(t, 5*time.Second, "the door", func() bool {
|
||||
c, err := net.Dial("tcp", addr)
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
}
|
||||
return err == nil
|
||||
})
|
||||
impostor := newTestCA(t, "impostor")
|
||||
cases := map[string]struct {
|
||||
cert tls.Certificate
|
||||
protos []string
|
||||
}{
|
||||
"another CA": {impostor.serverCert(t, tcpServerName), []string{laneALPN}},
|
||||
"the Host's own name": {ca.serverCert(t, "home.cseries.localhost"), []string{laneALPN}},
|
||||
"no protocol": {ca.serverCert(t, tcpServerName), nil},
|
||||
"pairing, no window": {ca.serverCert(t, tcpServerName), []string{pairALPN}},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
raw, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := tls.Server(raw, &tls.Config{Certificates: []tls.Certificate{tc.cert}, NextProtos: tc.protos,
|
||||
ClientAuth: tls.RequestClientCert})
|
||||
_ = s.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
herr := s.Handshake()
|
||||
if herr == nil {
|
||||
// TLS 1.3: the server's side can finish before the client's
|
||||
// verdict arrives; the verdict is the connection closing.
|
||||
_, herr = s.Read(make([]byte, 1))
|
||||
}
|
||||
if herr == nil {
|
||||
t.Fatalf("%s: the door kept the connection", name)
|
||||
}
|
||||
if got := len(s.ConnectionState().PeerCertificates); got != 0 {
|
||||
t.Fatalf("%s: the door presented this bastion's certificate to a stranger", name)
|
||||
}
|
||||
_ = raw.Close()
|
||||
}
|
||||
// Nothing was pooled: a lane's dial finds no connection. Its wait is
|
||||
// cut short by the context: doorTakeWait is a constant, read by every
|
||||
// lane, an earlier test's still running ones too.
|
||||
short, cancelShort := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancelShort()
|
||||
if c, err := hostDoorDial(cfg)(short); err == nil {
|
||||
_ = c.Close()
|
||||
t.Fatal("a refused connection reached the pool")
|
||||
}
|
||||
if !strings.Contains(pairingAuditText(t), `"door_refused"`) {
|
||||
t.Fatal("the refusals are not in the audit")
|
||||
}
|
||||
}
|
||||
|
||||
// The Host redials, the bastion never does: a Host that stops and comes back
|
||||
// gets its lanes again, and so does a bastion that restarts, all on
|
||||
// connections the Host opens. A connection the Host closes while idle leaves
|
||||
// the pool at once.
|
||||
func TestTheHostReconnectsAfterEitherSideRestarts(t *testing.T) {
|
||||
pairingBox(t)
|
||||
laneTestProcess(t)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
addr := freeDoorAddr(t)
|
||||
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
|
||||
h := newStandinHost(t, ca, "home", cfg.HostName, addr)
|
||||
beats := func() int { _, b := h.lane.snapshot(); return len(b) }
|
||||
|
||||
stop := runPairingBG(t, cfg)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go h.serveLanes(ctx)
|
||||
doorWaitFor(t, 10*time.Second, "the first heartbeat", func() bool { return beats() >= 1 })
|
||||
|
||||
// The Host stops: every connection closes, and the pool empties.
|
||||
cancel()
|
||||
doorWaitFor(t, 5*time.Second, "the pool to empty", func() bool {
|
||||
doors.Lock()
|
||||
d := doors.m[addr]
|
||||
doors.Unlock()
|
||||
d.mu.Lock()
|
||||
p := d.pairings[cfg.HostName]
|
||||
d.mu.Unlock()
|
||||
return p.live.Load() == 0
|
||||
})
|
||||
doorWaitFor(t, 5*time.Second, "the Host going away in the audit", func() bool {
|
||||
return strings.Contains(pairingAuditText(t), `"host_disconnected"`)
|
||||
})
|
||||
// It comes back: a beat at once (the reconnect brings the lanes up; the
|
||||
// beat loop's next tick or a new run sends it).
|
||||
n := beats()
|
||||
ctx2, cancel2 := context.WithCancel(context.Background())
|
||||
defer cancel2()
|
||||
go h.serveLanes(ctx2)
|
||||
if err := stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The bastion restarts while the Host keeps redialling.
|
||||
stop = runPairingBG(t, cfg)
|
||||
defer stop()
|
||||
doorWaitFor(t, 15*time.Second, "a heartbeat after both restarts", func() bool { return beats() > n })
|
||||
if h.dials.Load() < 2*hostSpareConns {
|
||||
t.Fatalf("the Host opened %d connections over two runs", h.dials.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// The terminal closing the window and a door committing a pairing on it are
|
||||
// one rename each, across processes: whichever comes first wins. A window
|
||||
// closed first is never claimed; a window claimed first is reported as the
|
||||
// pairing it made, never "nothing was paired".
|
||||
func TestClosingAWindowAndClaimingItCannotBothWin(t *testing.T) {
|
||||
pairingBox(t)
|
||||
open := func(id string) {
|
||||
w := &pairWindow{ID: id, Code: "0123456789ABCDEFGHJK", Door: ":8812", Opened: time.Now().UTC().Format(time.RFC3339),
|
||||
Expires: time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}
|
||||
if err := saveWindowFile(w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
open("a")
|
||||
if removeWindow("a") {
|
||||
t.Fatal("an unclaimed window closed as claimed")
|
||||
}
|
||||
if claimWindow("a") {
|
||||
t.Fatal("a window closed at the terminal was claimed")
|
||||
}
|
||||
open("b")
|
||||
if !claimWindow("b") {
|
||||
t.Fatal("an open window could not be claimed")
|
||||
}
|
||||
if !removeWindow("b") {
|
||||
t.Fatal("closing a claimed window did not say it was claimed")
|
||||
}
|
||||
endWindow("b", windowResult{Host: "home.cseries.localhost", State: pairingActive})
|
||||
if res := awaitClaimedResult("b"); res == nil || res.Host != "home.cseries.localhost" {
|
||||
t.Fatalf("the claimed window's result: %+v", res)
|
||||
}
|
||||
if claimedWindow("b") != nil {
|
||||
t.Fatal("the claimed file outlived its result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A connection that finishes its handshake after its pairing stopped is
|
||||
// closed, never held idle with a watcher nobody will ever stop.
|
||||
func TestAConnectionPutAfterThePairingStoppedIsClosed(t *testing.T) {
|
||||
pairingBox(t)
|
||||
p := &doorPairing{host: "home", idle: make(chan *hostOpenedConn, doorMaxIdle)}
|
||||
p.drain()
|
||||
a, b := net.Pipe()
|
||||
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
|
||||
p.put(tls.Client(a, &tls.Config{InsecureSkipVerify: true}), "192.0.2.1")
|
||||
if n, live := len(p.idle), p.live.Load(); n != 0 || live != 0 {
|
||||
t.Fatalf("a stopped pairing holds %d idle connections, %d live", n, live)
|
||||
}
|
||||
_ = b.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if n, err := b.Read(make([]byte, 1)); err != io.EOF {
|
||||
t.Fatalf("the connection was left open (read %d, %v)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every connection that was offered the window while it was open runs into
|
||||
// the same rules when its exchange starts: a window closed by the fifth wrong
|
||||
// code, inside a wrong code's wait, or closed at the terminal, starts nothing.
|
||||
func TestAWindowClosedDuringAHandshakeStartsNothing(t *testing.T) {
|
||||
pairingBox(t)
|
||||
addr := freeDoorAddr(t)
|
||||
w := &pairWindow{ID: "w1", Code: strings.Repeat("A", windowCodeLen), Door: addr,
|
||||
Expires: time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}
|
||||
if err := saveWindowFile(w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := newHostDoor(addr, false)
|
||||
if d.openWindow() == nil {
|
||||
t.Fatal("the window is not offered")
|
||||
}
|
||||
|
||||
d.win.failures = windowMaxFailures
|
||||
if d.windowBegin(w) {
|
||||
t.Fatal("a pairing started after the fifth wrong code")
|
||||
}
|
||||
d.win.failures, d.win.next = 1, time.Now().Add(time.Hour)
|
||||
if d.windowBegin(w) {
|
||||
t.Fatal("a pairing started inside a wrong code's wait")
|
||||
}
|
||||
d.win.failures, d.win.next = 0, time.Time{}
|
||||
removeWindow(w.ID)
|
||||
if d.windowBegin(w) {
|
||||
t.Fatal("a pairing started on a window closed at the terminal")
|
||||
}
|
||||
}
|
||||
|
||||
// A window closed at the terminal while the exchange runs: the Host is not
|
||||
// asked to sign anything.
|
||||
func TestAWindowClosedDuringTheExchangeIsNeverRedeemed(t *testing.T) {
|
||||
pairingBox(t)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
var mu sync.Mutex
|
||||
var paths []string
|
||||
c := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
mu.Lock()
|
||||
paths = append(paths, r.URL.Path)
|
||||
mu.Unlock()
|
||||
return nil, fmt.Errorf("no Host here")
|
||||
})}
|
||||
w := &pairWindow{ID: "gone", Code: strings.Repeat("A", windowCodeLen), Door: ":8812"}
|
||||
_, res := windowCommit(c, w, windowHelloResp{HostHandle: "home", HostName: "home.cseries.localhost"}, ca.pem,
|
||||
func() string { return "proof" })
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(paths) != 0 || !strings.Contains(res.Error, "window closed") {
|
||||
t.Fatalf("a closed window was redeemed: requests %v, result %+v", paths, res)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// One IPv6 machine is one source: its /64 counts as one address.
|
||||
func TestTheHandshakeLimitCountsAnIPv6NetworkAsOneSource(t *testing.T) {
|
||||
d := newHostDoor(":0", true)
|
||||
for i := 1; i <= doorMaxHandshakesPerSource; i++ {
|
||||
if !d.admitHandshake(sourceKey(fmt.Sprintf("2001:db8:1:2::%x", i))) {
|
||||
t.Fatalf("handshake %d refused", i)
|
||||
}
|
||||
}
|
||||
if d.admitHandshake(sourceKey("2001:db8:1:2::ffff")) {
|
||||
t.Fatal("a ninth handshake from one /64 was admitted")
|
||||
}
|
||||
if !d.admitHandshake(sourceKey("2001:db8:1:3::1")) || !d.admitHandshake(sourceKey("192.0.2.7")) {
|
||||
t.Fatal("another source was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A scan from many addresses writes a bounded number of refusals.
|
||||
func TestAScanFromManyAddressesDoesNotFillTheAudit(t *testing.T) {
|
||||
pairingBox(t)
|
||||
d := newHostDoor(":0", true)
|
||||
for i := 0; i < 4*doorRefusalAuditSources; i++ {
|
||||
d.refuse(fmt.Sprintf("2001:db8:%x::1", i), errors.New("scan"))
|
||||
}
|
||||
if n := strings.Count(pairingAuditText(t), `"door_refused"`); n > doorRefusalAuditSources {
|
||||
t.Fatalf("%d refusals audited from a scan, at most %d", n, doorRefusalAuditSources)
|
||||
}
|
||||
if len(d.refused) > doorRefusalAuditSources {
|
||||
t.Fatalf("the refusal map grew to %d", len(d.refused))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// The door's handshake slots under a flood: the global and per-source caps,
|
||||
// the share kept for addresses a Host proved itself from, and the random
|
||||
// early drop for strangers (hostdoor.go, admitHandshake).
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// freshDoorMemory forgets every Host address, before and after the test.
|
||||
func freshDoorMemory(t *testing.T) {
|
||||
t.Helper()
|
||||
reset := func() {
|
||||
doorKnown.Lock()
|
||||
doorKnown.until = nil
|
||||
doorKnown.Unlock()
|
||||
}
|
||||
reset()
|
||||
t.Cleanup(reset)
|
||||
}
|
||||
|
||||
// neverDrops is a draw the early drop never refuses on (it refuses when the
|
||||
// draw is below the drop chance, which is under 1 short of the limit).
|
||||
func neverDrops() float64 { return math.Nextafter(1, 0) }
|
||||
|
||||
// alwaysDrops is a draw the early drop refuses on wherever it applies.
|
||||
func alwaysDrops() float64 { return 0 }
|
||||
|
||||
func strangersShare() int { return doorMaxHandshakes - doorReservedHandshakes }
|
||||
|
||||
// The global and per-source caps hold for everyone, a known Host's address
|
||||
// included, and strangers never take the reserved share, however lucky.
|
||||
func TestTheHandshakeCapsStillHold(t *testing.T) {
|
||||
freshDoorMemory(t)
|
||||
now := time.Now()
|
||||
d := newHostDoor(":0", true)
|
||||
d.draw = neverDrops
|
||||
// Strangers: admitted up to their share, from enough addresses, and not
|
||||
// one more.
|
||||
for i := 0; i < strangersShare(); i++ {
|
||||
if !d.admitHandshake(fmt.Sprintf("203.0.113.%d", i/doorMaxHandshakesPerSource)) {
|
||||
t.Fatalf("stranger handshake %d refused below the strangers' share", i)
|
||||
}
|
||||
}
|
||||
if d.admitHandshake("198.51.100.200") {
|
||||
t.Fatalf("a stranger took a slot of the reserve (%d in progress)", d.inflight)
|
||||
}
|
||||
// Known addresses: the rest, 8 from each, and then nothing for anyone.
|
||||
for i := 0; d.inflight < doorMaxHandshakes; i++ {
|
||||
src := fmt.Sprintf("198.51.100.%d", i)
|
||||
doorRememberHost(src, now)
|
||||
for j := 0; j < doorMaxHandshakesPerSource; j++ {
|
||||
if !d.admitHandshake(src) {
|
||||
t.Fatalf("handshake %d of known %s refused with %d in progress", j, src, d.inflight)
|
||||
}
|
||||
}
|
||||
if d.inflight < doorMaxHandshakes && d.admitHandshake(src) {
|
||||
t.Fatalf("known %s got a ninth handshake", src)
|
||||
}
|
||||
}
|
||||
doorRememberHost("192.0.2.1", now)
|
||||
if d.inflight != doorMaxHandshakes || d.admitHandshake("192.0.2.1") {
|
||||
t.Fatalf("a handshake past the global cap of %d (%d in progress)", doorMaxHandshakes, d.inflight)
|
||||
}
|
||||
// A known address is held to 8 at once on an empty door too.
|
||||
e := newHostDoor(":0", true)
|
||||
for j := 0; j < doorMaxHandshakesPerSource; j++ {
|
||||
if !e.admitHandshake("192.0.2.1") {
|
||||
t.Fatal("a known address was refused on an empty door")
|
||||
}
|
||||
}
|
||||
if e.admitHandshake("192.0.2.1") {
|
||||
t.Fatal("a known address got a ninth handshake at once")
|
||||
}
|
||||
}
|
||||
|
||||
// Strangers are dropped early as the door fills: never below the start,
|
||||
// with the chance rising from the rate to certain at the strangers' share.
|
||||
// A known Host's address is never early-dropped.
|
||||
func TestStrangersAreDroppedEarlyAsTheDoorFills(t *testing.T) {
|
||||
freshDoorMemory(t)
|
||||
d := newHostDoor(":0", true)
|
||||
full := strangersShare()
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
draw float64
|
||||
admits bool
|
||||
}{
|
||||
{0, 0, true},
|
||||
{doorEarlyDropStart - 1, 0, true},
|
||||
{doorEarlyDropStart, doorEarlyDropRate - 0.01, false},
|
||||
{doorEarlyDropStart, doorEarlyDropRate + 0.01, true},
|
||||
{(doorEarlyDropStart + full) / 2, (1+doorEarlyDropRate)/2 - 0.01, false},
|
||||
{(doorEarlyDropStart + full) / 2, (1+doorEarlyDropRate)/2 + 0.01, true},
|
||||
{full - 1, 0.99, false},
|
||||
{full - 1, neverDrops(), true},
|
||||
{full, neverDrops(), false},
|
||||
} {
|
||||
draw := c.draw
|
||||
d.draw = func() float64 { return draw }
|
||||
if got := d.admitStranger(c.n); got != c.admits {
|
||||
t.Fatalf("with %d in progress and a draw of %v: admitted %v, want %v", c.n, c.draw, got, c.admits)
|
||||
}
|
||||
}
|
||||
// Through admitHandshake: an unlucky stranger is refused at the start,
|
||||
// a known address with the same draw is not, up to the global cap.
|
||||
d = newHostDoor(":0", true)
|
||||
d.draw = alwaysDrops
|
||||
for i := 0; i < doorEarlyDropStart; i++ {
|
||||
if !d.admitHandshake(fmt.Sprintf("203.0.113.%d", i)) {
|
||||
t.Fatalf("stranger %d refused below the start", i)
|
||||
}
|
||||
}
|
||||
if d.admitHandshake("203.0.113.250") {
|
||||
t.Fatal("an unlucky stranger was admitted past the start")
|
||||
}
|
||||
doorRememberHost("198.51.100.7", time.Now())
|
||||
d.inflight = doorMaxHandshakes - 1
|
||||
if !d.admitHandshake("198.51.100.7") {
|
||||
t.Fatal("a known Host's address was early-dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// An address is remembered for doorKnownHostFor after its last verified
|
||||
// handshake, in a bounded memory that forgets the oldest first.
|
||||
func TestAHostAddressIsRememberedForAWhileAndBounded(t *testing.T) {
|
||||
freshDoorMemory(t)
|
||||
now := time.Now()
|
||||
doorRememberHost("198.51.100.7", now)
|
||||
if !doorKnownHost("198.51.100.7", now.Add(doorKnownHostFor-time.Second)) {
|
||||
t.Fatal("forgotten before its time")
|
||||
}
|
||||
if doorKnownHost("198.51.100.7", now.Add(doorKnownHostFor)) {
|
||||
t.Fatal("remembered past its time")
|
||||
}
|
||||
if doorKnownHost("198.51.100.8", now) {
|
||||
t.Fatal("an address no Host proved itself from is known")
|
||||
}
|
||||
// A fresh handshake renews it.
|
||||
doorRememberHost("198.51.100.7", now)
|
||||
doorRememberHost("198.51.100.7", now.Add(doorKnownHostFor-time.Second))
|
||||
if !doorKnownHost("198.51.100.7", now.Add(doorKnownHostFor+time.Hour)) {
|
||||
t.Fatal("a renewed address was forgotten")
|
||||
}
|
||||
// Bounded: the oldest goes first, the newest stays.
|
||||
for i := 0; i < 2*doorKnownHostsMax; i++ {
|
||||
doorRememberHost(fmt.Sprintf("2001:db8:%x::/64", i), now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
doorKnown.Lock()
|
||||
n := len(doorKnown.until)
|
||||
doorKnown.Unlock()
|
||||
if n > doorKnownHostsMax {
|
||||
t.Fatalf("the memory holds %d addresses, at most %d", n, doorKnownHostsMax)
|
||||
}
|
||||
last := now.Add(time.Duration(2*doorKnownHostsMax) * time.Second)
|
||||
if !doorKnownHost(fmt.Sprintf("2001:db8:%x::/64", 2*doorKnownHostsMax-1), last) {
|
||||
t.Fatal("the newest address was forgotten")
|
||||
}
|
||||
if doorKnownHost("2001:db8:0::/64", last) {
|
||||
t.Fatal("the oldest address outlived a full memory")
|
||||
}
|
||||
}
|
||||
|
||||
// pipeDoor is a door served over in-memory connections from any address the
|
||||
// test names, with one pairing for ca's Host.
|
||||
type pipeDoor struct {
|
||||
d *hostDoor
|
||||
p *doorPairing
|
||||
ca *testCA
|
||||
conns chan net.Conn
|
||||
quit chan struct{}
|
||||
accepts atomic.Int64
|
||||
pushed int64
|
||||
mu sync.Mutex
|
||||
ends []net.Conn
|
||||
}
|
||||
|
||||
func (l *pipeDoor) Accept() (net.Conn, error) {
|
||||
l.accepts.Add(1)
|
||||
select {
|
||||
case c := <-l.conns:
|
||||
return c, nil
|
||||
case <-l.quit:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
func (l *pipeDoor) Close() error { return nil }
|
||||
func (l *pipeDoor) Addr() net.Addr { return &net.TCPAddr{Port: 8812} }
|
||||
|
||||
type fromConn struct {
|
||||
net.Conn
|
||||
from net.Addr
|
||||
}
|
||||
|
||||
func (c fromConn) RemoteAddr() net.Addr { return c.from }
|
||||
|
||||
func newPipeDoor(t *testing.T) *pipeDoor {
|
||||
t.Helper()
|
||||
ca := newTestCA(t, "home host CA")
|
||||
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", ":8812")
|
||||
p, err := newDoorPairing(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := &pipeDoor{d: newHostDoor(":8812", true), p: p, ca: ca, conns: make(chan net.Conn), quit: make(chan struct{})}
|
||||
l.d.pairings[cfg.HostName] = p
|
||||
go l.d.serve(l)
|
||||
t.Cleanup(func() {
|
||||
close(l.quit)
|
||||
l.mu.Lock()
|
||||
for _, c := range l.ends {
|
||||
_ = c.Close()
|
||||
}
|
||||
l.mu.Unlock()
|
||||
p.drain()
|
||||
doorWaitFor(t, 15*time.Second, "every handshake to end", func() bool { return l.inflight() == 0 })
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
// connect hands the door a connection from ip and waits until the door has
|
||||
// admitted or closed it. It returns the far end.
|
||||
func (l *pipeDoor) connect(t *testing.T, ip string) net.Conn {
|
||||
t.Helper()
|
||||
near, far := net.Pipe()
|
||||
l.mu.Lock()
|
||||
l.ends = append(l.ends, far)
|
||||
l.mu.Unlock()
|
||||
l.conns <- fromConn{Conn: near, from: &net.TCPAddr{IP: net.ParseIP(ip), Port: 40000}}
|
||||
l.pushed++
|
||||
for l.accepts.Load() <= l.pushed {
|
||||
time.Sleep(50 * time.Microsecond)
|
||||
}
|
||||
return far
|
||||
}
|
||||
|
||||
// stranger connects from ip and never answers the handshake: a slot held
|
||||
// until the handshake's time runs out.
|
||||
func (l *pipeDoor) stranger(t *testing.T, ip string) {
|
||||
far := l.connect(t, ip)
|
||||
go func() { _, _ = io.Copy(io.Discard, far) }()
|
||||
}
|
||||
|
||||
// host connects from ip as the pairing's Host and returns how long its
|
||||
// handshake took, or the error that ended it.
|
||||
func (l *pipeDoor) host(t *testing.T, ip string, within time.Duration) (time.Duration, error) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
far := l.connect(t, ip)
|
||||
s := tls.Server(far, &tls.Config{Certificates: []tls.Certificate{l.ca.serverCert(t, tcpServerName)},
|
||||
NextProtos: []string{laneALPN}, MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAnyClientCert})
|
||||
_ = s.SetDeadline(start.Add(within))
|
||||
err := s.Handshake()
|
||||
_ = s.SetDeadline(time.Time{})
|
||||
return time.Since(start), err
|
||||
}
|
||||
|
||||
func (l *pipeDoor) inflight() int {
|
||||
l.d.mu.Lock()
|
||||
defer l.d.mu.Unlock()
|
||||
return l.d.inflight
|
||||
}
|
||||
|
||||
// hostReconnectBound is how long the person's own Host may take to get a
|
||||
// connection through a flooded door: its first try is admitted, so this is
|
||||
// one handshake over an in-memory connection, with room for -race and a
|
||||
// loaded machine. A refused try instead costs the Host's backoff (1 s, then
|
||||
// doubling to 30 s, docs/host-connects.md).
|
||||
const hostReconnectBound = 2 * time.Second
|
||||
|
||||
// SECURITY: 64 addresses flooding the door hold every slot strangers may
|
||||
// take, and the person's own Host, connecting from the address it proved
|
||||
// itself from, gets in at its first try, every time, within
|
||||
// hostReconnectBound. The same Host from an address it never proved itself
|
||||
// from waits like any stranger; what the reserve keeps is the address.
|
||||
func TestTheOwnHostGetsInWhileStrangersFloodTheDoor(t *testing.T) {
|
||||
pairingBox(t)
|
||||
freshDoorMemory(t)
|
||||
l := newPipeDoor(t)
|
||||
const home = "198.51.100.7"
|
||||
// Before the flood: a Host that connected once is known by its address.
|
||||
if _, err := l.host(t, home, hostReconnectBound); err != nil {
|
||||
t.Fatalf("the Host's first connection: %v", err)
|
||||
}
|
||||
doorWaitFor(t, 5*time.Second, "the connection in the pool", func() bool { return l.p.live.Load() == 1 })
|
||||
if !doorKnownHost(home, time.Now()) {
|
||||
t.Fatal("a Host's verified connection did not make its address known")
|
||||
}
|
||||
// The flood: 64 addresses, round and round, until every slot strangers
|
||||
// may hold is held, with the real random early drop.
|
||||
attempts := 0
|
||||
for l.inflight() < strangersShare() {
|
||||
if attempts++; attempts > 50000 {
|
||||
t.Fatalf("the flood holds %d slots after %d connections", l.inflight(), attempts)
|
||||
}
|
||||
l.stranger(t, fmt.Sprintf("203.0.113.%d", attempts%64))
|
||||
}
|
||||
// Full for strangers: from any address, new or flooding, none gets in.
|
||||
for i := 0; i < 64; i++ {
|
||||
l.stranger(t, fmt.Sprintf("192.0.2.%d", i))
|
||||
}
|
||||
if n := l.inflight(); n != strangersShare() {
|
||||
t.Fatalf("strangers hold %d slots, at most %d", n, strangersShare())
|
||||
}
|
||||
// The Host reconnects: each of its spare connections at the first try.
|
||||
for i := 0; i < hostSpareConns; i++ {
|
||||
took, err := l.host(t, home, hostReconnectBound)
|
||||
if err != nil {
|
||||
t.Fatalf("the Host's connection %d during the flood (after %d flood connections): %v", i, attempts, err)
|
||||
}
|
||||
if took > hostReconnectBound {
|
||||
t.Fatalf("the Host's connection %d took %s, bound %s", i, took, hostReconnectBound)
|
||||
}
|
||||
t.Logf("the Host's connection %d through the flood (%d flood connections): %s", i, attempts, took)
|
||||
}
|
||||
doorWaitFor(t, 5*time.Second, "the Host's connections in the pool", func() bool {
|
||||
return l.p.live.Load() == 1+hostSpareConns
|
||||
})
|
||||
// The same Host from an address it never proved itself from: refused.
|
||||
if _, err := l.host(t, "198.51.100.99", hostReconnectBound); err == nil {
|
||||
t.Fatal("an unknown address got past a door full for strangers")
|
||||
}
|
||||
}
|
||||
|
||||
// A Host never seen before pairs through the window when nobody floods the
|
||||
// door, even with a draw the early drop would refuse on, and its address is
|
||||
// known from then on, so its lane connections count as its own.
|
||||
func TestANewHostPairsWhenNobodyFloods(t *testing.T) {
|
||||
pairingBox(t)
|
||||
freshDoorMemory(t)
|
||||
prev := doorDraw
|
||||
doorDraw = alwaysDrops
|
||||
t.Cleanup(func() { doorDraw = prev })
|
||||
addr := freeDoorAddr(t)
|
||||
code, out, res := openWindowFor(t, addr)
|
||||
ca := newTestCA(t, "home host CA")
|
||||
h := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
|
||||
done, err := h.pair(formatWindowCode(code))
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, pairingAuditText(t))
|
||||
}
|
||||
if done.State != pairingActive {
|
||||
t.Fatalf("done: %+v", done)
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
if err != nil {
|
||||
t.Fatalf("pair --open: %v\n%s", err, out.String())
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("pair --open never saw the pairing end its window")
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
if !doorKnownHost(sourceKey(host), time.Now()) {
|
||||
t.Fatal("a Host that paired through the window is not known by its address")
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: only a verified handshake for a pairing the door still serves
|
||||
// makes an address known: a certificate under another CA does not, nor the
|
||||
// Host's own certificate once the door has stopped serving its pairing.
|
||||
func TestOnlyAServedHostsVerifiedHandshakeMakesItsAddressKnown(t *testing.T) {
|
||||
pairingBox(t)
|
||||
freshDoorMemory(t)
|
||||
l := newPipeDoor(t)
|
||||
// handshake connects from ip with cert and waits for the door's verdict,
|
||||
// the connection closing, so whatever the door does for it is done.
|
||||
handshake := func(ip string, cert tls.Certificate) {
|
||||
far := l.connect(t, ip)
|
||||
s := tls.Server(far, &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{laneALPN},
|
||||
MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAnyClientCert})
|
||||
_ = s.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
if err := s.Handshake(); err == nil {
|
||||
_, _ = s.Read(make([]byte, 1))
|
||||
}
|
||||
_ = s.Close()
|
||||
}
|
||||
const impostorIP, stoppedIP = "198.51.100.60", "198.51.100.61"
|
||||
impostor := newTestCA(t, "impostor")
|
||||
handshake(impostorIP, impostor.serverCert(t, tcpServerName))
|
||||
if doorKnownHost(impostorIP, time.Now()) {
|
||||
t.Fatal("a certificate under another CA made its address known")
|
||||
}
|
||||
l.d.mu.Lock()
|
||||
l.d.closed = true
|
||||
l.d.mu.Unlock()
|
||||
handshake(stoppedIP, l.ca.serverCert(t, tcpServerName))
|
||||
if doorKnownHost(stoppedIP, time.Now()) {
|
||||
t.Fatal("a handshake for a pairing the door no longer serves made its address known")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// A stand-in for a Host that connects to a server (docs/host-connects.md):
|
||||
// it pairs through a window with the code, then keeps hostSpareConns idle
|
||||
// lane connections open to the server's door and serves the lane routes of
|
||||
// newLaneHost over them. The end-to-end run (hostdoor_e2e_linux_test.go)
|
||||
// runs it in a network namespace behind a NAT.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hostSpareConns is how many idle connections the stand-in keeps open, the
|
||||
// number docs/host-connects.md asks a Host to keep.
|
||||
const hostSpareConns = 4
|
||||
|
||||
type standinHost struct {
|
||||
ca *testCA
|
||||
handle string
|
||||
name string
|
||||
server string // the door, host:port
|
||||
|
||||
// pairing
|
||||
pairLeaf tls.Certificate // leaf + CA, as a pairing presents them
|
||||
lieLeaf bool // prove over another certificate (a relayed proof)
|
||||
badProof bool // check the bastion's proof against the wrong code
|
||||
bearerN atomic.Int32
|
||||
mu sync.Mutex
|
||||
done []windowDoneReq
|
||||
csrs int
|
||||
|
||||
// lanes
|
||||
lane *laneHost
|
||||
handler http.Handler
|
||||
laneTLS *tls.Config
|
||||
dials atomic.Int32
|
||||
idle atomic.Int32
|
||||
open atomic.Int32
|
||||
}
|
||||
|
||||
func newStandinHost(t *testing.T, ca *testCA, handle, name, server string) *standinHost {
|
||||
t.Helper()
|
||||
pl := ca.serverCert(t, tcpServerName)
|
||||
pl.Certificate = append(pl.Certificate, ca.cert.Raw)
|
||||
h := &standinHost{ca: ca, handle: handle, name: name, server: server, pairLeaf: pl}
|
||||
h.lane, h.handler, h.laneTLS = newLaneHost(ca.serverCert(t, tcpServerName), ca)
|
||||
return h
|
||||
}
|
||||
|
||||
// pair connects to the door and pairs with code. It returns what the
|
||||
// bastion's done said, or the error that ended the exchange.
|
||||
func (h *standinHost) pair(code string) (windowDoneReq, error) {
|
||||
norm := normalizeWindowCode(code)
|
||||
raw, err := net.DialTimeout("tcp", h.server, 5*time.Second)
|
||||
if err != nil {
|
||||
return windowDoneReq{}, err
|
||||
}
|
||||
tc := tls.Server(raw, &tls.Config{Certificates: []tls.Certificate{h.pairLeaf}, NextProtos: []string{pairALPN},
|
||||
MinVersion: tls.VersionTLS13})
|
||||
_ = tc.SetDeadline(time.Now().Add(20 * time.Second))
|
||||
if err := tc.Handshake(); err != nil {
|
||||
_ = raw.Close()
|
||||
return windowDoneReq{}, fmt.Errorf("handshake: %w", err)
|
||||
}
|
||||
leafSum := sha256.Sum256(h.pairLeaf.Certificate[0])
|
||||
if h.lieLeaf {
|
||||
leafSum = sha256.Sum256([]byte("another connection's certificate"))
|
||||
}
|
||||
caSum := sha256.Sum256(h.ca.cert.Raw)
|
||||
var nb, nh []byte
|
||||
gotDone := make(chan windowDoneReq, 1)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/pair/hello", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req windowHelloReq
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
nb, _ = hex.DecodeString(req.Nonce)
|
||||
nh = make([]byte, 32)
|
||||
_, _ = rand.Read(nh)
|
||||
_ = json.NewEncoder(w).Encode(windowHelloResp{V: 1, HostHandle: h.handle, HostName: h.name,
|
||||
Nonce: hex.EncodeToString(nh), Proof: windowProof(norm, "host", leafSum[:], caSum[:], nb, nh)})
|
||||
})
|
||||
mux.HandleFunc("/v1/pair/redeem", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req windowRedeemReq
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
key := norm
|
||||
if h.badProof {
|
||||
key = "0000000000000000000Z"
|
||||
}
|
||||
real := sha256.Sum256(h.pairLeaf.Certificate[0])
|
||||
if req.Proof != windowProof(key, "bastion", real[:], caSum[:], nb, nh) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "this server did not prove the code"})
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.csrs++
|
||||
h.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"operator_cert": h.ca.signCSR(req.OperatorCSR), "ca_cert": h.ca.pem,
|
||||
"bearer": fmt.Sprintf("mcp_dials_%s_%d", h.handle, h.bearerN.Add(1)), "transport": windowTransport,
|
||||
"host_handle": h.handle, "host_name": h.name})
|
||||
})
|
||||
mux.HandleFunc("/v1/pair/done", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req windowDoneReq
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
h.mu.Lock()
|
||||
h.done = append(h.done, req)
|
||||
h.mu.Unlock()
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
gotDone <- req
|
||||
})
|
||||
ln := &oneConnListener{c: tc, closed: make(chan struct{})}
|
||||
srv := &http.Server{Handler: mux, ErrorLog: log.New(io.Discard, "", 0)}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
defer srv.Close()
|
||||
select {
|
||||
case d := <-gotDone:
|
||||
return d, nil
|
||||
case <-ln.gone():
|
||||
return windowDoneReq{}, errors.New("the server closed the pairing connection")
|
||||
case <-time.After(20 * time.Second):
|
||||
return windowDoneReq{}, errors.New("no done within 20 s")
|
||||
}
|
||||
}
|
||||
|
||||
// oneConnListener hands out one connection, then blocks until closed.
|
||||
type oneConnListener struct {
|
||||
c net.Conn
|
||||
once sync.Once
|
||||
closed chan struct{}
|
||||
cmu sync.Mutex
|
||||
shut bool
|
||||
}
|
||||
|
||||
func (l *oneConnListener) Accept() (net.Conn, error) {
|
||||
var c net.Conn
|
||||
l.once.Do(func() { c = &watchClose{Conn: l.c, l: l} })
|
||||
if c != nil {
|
||||
return c, nil
|
||||
}
|
||||
<-l.closed
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
func (l *oneConnListener) gone() <-chan struct{} { return l.closed }
|
||||
|
||||
func (l *oneConnListener) Close() error {
|
||||
l.cmu.Lock()
|
||||
defer l.cmu.Unlock()
|
||||
if !l.shut {
|
||||
l.shut = true
|
||||
close(l.closed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *oneConnListener) Addr() net.Addr { return l.c.LocalAddr() }
|
||||
|
||||
type watchClose struct {
|
||||
net.Conn
|
||||
l *oneConnListener
|
||||
}
|
||||
|
||||
func (w *watchClose) Read(b []byte) (int, error) {
|
||||
n, err := w.Conn.Read(b)
|
||||
var ne net.Error
|
||||
if err != nil && !(errors.As(err, &ne) && ne.Timeout()) {
|
||||
_ = w.l.Close() // the http server's own read aborts are timeouts
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// serveLanes keeps hostSpareConns idle connections open to the door until
|
||||
// ctx ends, serving the lane routes over each, and closes them all at the end
|
||||
// (a Host that stops). Redials back off from 100 ms to 2 s.
|
||||
func (h *standinHost) serveLanes(ctx context.Context) {
|
||||
acc := make(chan net.Conn)
|
||||
ln := &chanListener{c: acc, done: ctx.Done(), addr: &net.TCPAddr{}}
|
||||
srv := &http.Server{Handler: h.handler, ErrorLog: log.New(io.Discard, "", 0)}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
var all sync.Map
|
||||
kick := make(chan struct{}, 64)
|
||||
wait := 100 * time.Millisecond
|
||||
for ctx.Err() == nil {
|
||||
if h.idle.Load() >= hostSpareConns || h.open.Load() >= 64 {
|
||||
select {
|
||||
case <-kick:
|
||||
case <-time.After(time.Second):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue
|
||||
}
|
||||
h.dials.Add(1)
|
||||
d := net.Dialer{Timeout: 5 * time.Second, KeepAlive: 25 * time.Second}
|
||||
raw, err := d.DialContext(ctx, "tcp", h.server)
|
||||
if err == nil {
|
||||
tc := tls.Server(raw, h.laneTLS)
|
||||
_ = tc.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
if err = tc.HandshakeContext(ctx); err == nil {
|
||||
_ = tc.SetDeadline(time.Time{})
|
||||
h.idle.Add(1)
|
||||
h.open.Add(1)
|
||||
c := &usedConn{Conn: tc, h: h, kick: kick}
|
||||
all.Store(c, true)
|
||||
select {
|
||||
case acc <- c:
|
||||
case <-ctx.Done():
|
||||
_ = c.Close()
|
||||
}
|
||||
wait = 100 * time.Millisecond
|
||||
continue
|
||||
}
|
||||
_ = raw.Close()
|
||||
}
|
||||
select {
|
||||
case <-time.After(wait):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
wait = min(2*wait, 2*time.Second)
|
||||
}
|
||||
_ = srv.Close()
|
||||
all.Range(func(k, _ any) bool { _ = k.(*usedConn).Close(); return true })
|
||||
}
|
||||
|
||||
// usedConn tells the stand-in when a lane first speaks on a connection (it
|
||||
// is no longer idle) and when it closes.
|
||||
type usedConn struct {
|
||||
net.Conn
|
||||
h *standinHost
|
||||
kick chan struct{}
|
||||
used atomic.Bool
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *usedConn) Read(b []byte) (int, error) {
|
||||
n, err := c.Conn.Read(b)
|
||||
if n > 0 && c.used.CompareAndSwap(false, true) {
|
||||
c.h.idle.Add(-1)
|
||||
c.nudge()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *usedConn) Close() error {
|
||||
if c.closed.CompareAndSwap(false, true) {
|
||||
if c.used.CompareAndSwap(false, true) {
|
||||
c.h.idle.Add(-1)
|
||||
}
|
||||
c.h.open.Add(-1)
|
||||
c.nudge()
|
||||
}
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
func (c *usedConn) nudge() {
|
||||
select {
|
||||
case c.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type chanListener struct {
|
||||
c chan net.Conn
|
||||
done <-chan struct{}
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func (l *chanListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.c:
|
||||
return c, nil
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
func (l *chanListener) Close() error { return nil }
|
||||
func (l *chanListener) Addr() net.Addr { return l.addr }
|
||||
|
||||
// TestStandinHostProcess is the stand-in as a process of its own, for the
|
||||
// end-to-end run behind a NAT (scripts/e2e-host-connects.sh) and on another
|
||||
// machine's bastion. Skipped unless STANDIN_MODE is set:
|
||||
//
|
||||
// STANDIN_MODE=pair STANDIN_STATE=<dir> STANDIN_SERVER=<host:port> STANDIN_CODE=<code>
|
||||
// STANDIN_MODE=serve STANDIN_STATE=<dir> STANDIN_SERVER=<host:port>
|
||||
//
|
||||
// The CA is kept in STANDIN_STATE, so a restarted stand-in is the same Host.
|
||||
func TestStandinHostProcess(t *testing.T) {
|
||||
mode := os.Getenv("STANDIN_MODE")
|
||||
if mode == "" {
|
||||
t.Skip("the stand-in Host process (scripts/e2e-host-connects.sh)")
|
||||
}
|
||||
dir, server := os.Getenv("STANDIN_STATE"), os.Getenv("STANDIN_SERVER")
|
||||
handle := firstNonEmpty(os.Getenv("STANDIN_HANDLE"), "home")
|
||||
name := firstNonEmpty(os.Getenv("STANDIN_NAME"), handle+".cseries.localhost")
|
||||
ca := standinCA(t, dir, name)
|
||||
h := newStandinHost(t, ca, handle, name, server)
|
||||
logf := func(f string, a ...any) {
|
||||
fmt.Printf(time.Now().UTC().Format("15:04:05.000Z ")+"standin "+f+"\n", a...)
|
||||
}
|
||||
switch mode {
|
||||
case "pair":
|
||||
d, err := h.pair(os.Getenv("STANDIN_CODE"))
|
||||
if err != nil {
|
||||
logf("PAIR FAILED: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
logf("PAIRED: done=%+v csrs=%d", d, h.csrs)
|
||||
case "serve":
|
||||
h.lane.jobs <- []byte(`{"harness_id":"e2e","mode":"echo","prompt":"a job over a connection the Host opened"}`)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go h.serveLanes(ctx)
|
||||
var lb, lh int
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
hellos, beats := h.lane.snapshot()
|
||||
if len(beats) != lb || len(hellos) != lh {
|
||||
logf("lanes: dials=%d open=%d idle=%d beats=%d worker-hellos=%d", h.dials.Load(), h.open.Load(), h.idle.Load(), len(beats), len(hellos))
|
||||
lb, lh = len(beats), len(hellos)
|
||||
}
|
||||
select {
|
||||
case r := <-h.lane.replies:
|
||||
logf("HARNESS JOB served over a Host-opened connection: %s", r)
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// standinCA is the stand-in's CA, made once and kept in dir.
|
||||
func standinCA(t *testing.T, dir, cn string) *testCA {
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
cp, kp := filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca.key")
|
||||
if c, err := os.ReadFile(cp); err == nil {
|
||||
k, _ := os.ReadFile(kp)
|
||||
cb, _ := pem.Decode(c)
|
||||
kb, _ := pem.Decode(k)
|
||||
if cb != nil && kb != nil {
|
||||
cert, err1 := x509.ParseCertificate(cb.Bytes)
|
||||
key, err2 := x509.ParseECPrivateKey(kb.Bytes)
|
||||
if err1 == nil && err2 == nil {
|
||||
return &testCA{cert: cert, key: key, pem: string(c)}
|
||||
}
|
||||
}
|
||||
}
|
||||
ca := newTestCA(t, cn+" stand-in CA")
|
||||
kd, _ := x509.MarshalECPrivateKey(ca.key)
|
||||
_ = os.WriteFile(cp, []byte(ca.pem), 0o600)
|
||||
_ = os.WriteFile(kp, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600)
|
||||
return ca
|
||||
}
|
||||
@@ -74,3 +74,83 @@ func TestInstallPs1ElevatesForTheWholeMachineUnderPwsh(t *testing.T) {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A whole-machine install replaces a program the tray holds open. The tray
|
||||
// runs from the service's own file in every session, Windows will not replace
|
||||
// a running program, and the install stopped with "Cannot create a file when
|
||||
// that file already exists." with the service it had stopped left stopped.
|
||||
// The program is moved aside instead, a failure starts the service again, and
|
||||
// this person's tray is started from the new program.
|
||||
//
|
||||
// MUTATIONS (run with testdata/install_ps1_swap.ps1 under pwsh): drop the move
|
||||
// aside from Install-Program; drop Start-Service from Stop-InstallFailed; drop
|
||||
// the session filter from Restart-Tray; let Unregister-ScheduledTask's failure
|
||||
// escape; drop the .old/.bak exclusion from the earlier-install stop. RESULT:
|
||||
// each red.
|
||||
func TestInstallPs1ReplacesAProgramTheTrayHoldsOpen(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.ps1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, want := range []string{
|
||||
"Install-Program $tmp $exe",
|
||||
"Move-Item -LiteralPath $target -Destination $aside",
|
||||
`foreach ($name in @("$target.old", "$target.bak"))`,
|
||||
"$stoppedService = $true",
|
||||
`Stop-InstallFailed $stoppedService "The Windows service could not be registered`,
|
||||
"Start-Service -Name 'PrivataeBastion' -ErrorAction Stop",
|
||||
"if (Restart-Tray $installedExe)",
|
||||
// The logon task is removed with the service stopped: a failure starts it again.
|
||||
`Stop-InstallFailed $stoppedService ("The logon task from an earlier install could not be removed`,
|
||||
// The trays running from the program moved aside are not an earlier install.
|
||||
"($asideNames -notcontains $_.Path)",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("install.ps1 lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "Move-Item -Force -Path $tmp -Destination $exe") {
|
||||
t.Error("install.ps1 moves the download over the program in place, which fails while a tray runs it")
|
||||
}
|
||||
// A per-user reinstall with no -Pair stops the copy already running (the
|
||||
// old program, moved aside) before it starts the task again, or it would
|
||||
// say "running now" over the old version.
|
||||
//
|
||||
// MUTATION: drop the Stop-Process loop from that branch. RESULT: red.
|
||||
if i := strings.Index(s, "# Started now, not only at the next sign-in"); i < 0 {
|
||||
t.Error("install.ps1 no longer starts a per-user bastion at install")
|
||||
} else if seg := s[i:]; !strings.Contains(seg[:strings.Index(seg, "Start-ScheduledTask")], "Stop-Process -Id $_.Id -Force") {
|
||||
t.Error("a per-user reinstall starts the task without stopping the copy already running the old program")
|
||||
}
|
||||
if strings.Contains(s, `throw "could not register the Windows service`) {
|
||||
t.Error("a failed service registration throws with the service it stopped left stopped")
|
||||
}
|
||||
// The aside names are ones the bastion clears at its next start.
|
||||
src, err := os.ReadFile("update.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), `[]string{".old", ".new", ".bak"}`) {
|
||||
t.Error("cleanupStaleBinaries no longer clears .old and .bak, which the installer leaves beside the program")
|
||||
}
|
||||
// The tray is started from the ordinary account, after the elevated step.
|
||||
if r, e := strings.Index(s, "if (Restart-Tray $installedExe)"), strings.Index(s, "-Verb RunAs"); r < 0 || e < 0 || r < e {
|
||||
t.Error("the tray is not restarted after the elevated step")
|
||||
}
|
||||
}
|
||||
|
||||
// Install-Program, Stop-InstallFailed and Restart-Tray, run by pwsh with
|
||||
// Windows stood in (testdata/install_ps1_swap.ps1). Skipped where pwsh is not
|
||||
// installed.
|
||||
func TestInstallPs1SwapRunsUnderPwsh(t *testing.T) {
|
||||
pwsh, err := exec.LookPath("pwsh")
|
||||
if err != nil {
|
||||
t.Skip("pwsh is not installed: install.ps1's swap is checked only by TestInstallPs1ReplacesAProgramTheTrayHoldsOpen here")
|
||||
}
|
||||
out, err := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File",
|
||||
filepath.Join("testdata", "install_ps1_swap.ps1"), filepath.Join("..", "deploy", "install.ps1")).CombinedOutput()
|
||||
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "ok:") {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
// lanedial.go: the one place a lane turns the connection its pairing's
|
||||
// transport hands it into the mutual TLS the lane runs inside.
|
||||
//
|
||||
// Every transport but one hands a lane a plain connection (the tunnel, the
|
||||
// lane socket, a Host's lane port), and the TLS is done here exactly as the
|
||||
// WebSocket library did it before: the pairing's lane configuration, the
|
||||
// server name it names, the handshake under the dial's context, and the name
|
||||
// checked once more. A Host that connects to this server (hostdoor.go) hands
|
||||
// the lane a connection whose TLS the door already did, under the same
|
||||
// configuration (the Host's CA pinned, host.cseries.internal, this bastion's
|
||||
// operator certificate): that one is passed on as it is. So the lane code is
|
||||
// the same for every pairing; only the dial differs.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// laneTLSDial is the TLS dial every lane uses: dialHost's connection with the
|
||||
// lane's TLS done on it, or a Host-opened connection as the door left it.
|
||||
func laneTLSDial(dialHost func(ctx context.Context) (net.Conn, error), tlsCfg *tls.Config) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
c, err := dialHost(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hc, ok := c.(*hostOpenedConn); ok {
|
||||
return hc, nil
|
||||
}
|
||||
cfg := tlsCfg.Clone()
|
||||
if cfg == nil {
|
||||
cfg = &tls.Config{}
|
||||
}
|
||||
if cfg.ServerName == "" {
|
||||
if h, _, err := net.SplitHostPort(addr); err == nil {
|
||||
cfg.ServerName = h
|
||||
} else {
|
||||
cfg.ServerName = addr
|
||||
}
|
||||
}
|
||||
tc := tls.Client(c, cfg)
|
||||
if err := tc.HandshakeContext(ctx); err != nil {
|
||||
_ = c.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.InsecureSkipVerify {
|
||||
if err := tc.VerifyHostname(cfg.ServerName); err != nil {
|
||||
_ = tc.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// laneWSDialer is the WebSocket dialer every lane worker pool uses.
|
||||
func laneWSDialer(tlsCfg *tls.Config, dialHost func(ctx context.Context) (net.Conn, error)) *websocket.Dialer {
|
||||
return &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
NetDialTLSContext: laneTLSDial(dialHost, tlsCfg),
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// laneHTTPTransport is the HTTP transport of a local pairing's requests (the
|
||||
// heartbeat, git credentials, store proposals, context pulls).
|
||||
func laneHTTPTransport(dialHost func(ctx context.Context) (net.Conn, error), tlsCfg *tls.Config) *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dialHost(ctx) },
|
||||
DialTLSContext: laneTLSDial(dialHost, tlsCfg),
|
||||
TLSClientConfig: tlsCfg.Clone(),
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,13 @@ import (
|
||||
type heldHost struct {
|
||||
hellos atomic.Int32
|
||||
hold atomic.Bool
|
||||
tls *tls.Config
|
||||
dial func(ctx context.Context) (net.Conn, error)
|
||||
url string
|
||||
// together: a held socket is closed only once this many hellos came in
|
||||
// (or after barrierWait), so every worker of a pool has dialled before
|
||||
// the first refusal starts the hold. 0 closes each at once.
|
||||
together atomic.Int32
|
||||
tls *tls.Config
|
||||
dial func(ctx context.Context) (net.Conn, error)
|
||||
url string
|
||||
}
|
||||
|
||||
func startHeldHost(t *testing.T) *heldHost {
|
||||
@@ -42,6 +46,9 @@ func startHeldHost(t *testing.T) *heldHost {
|
||||
}
|
||||
h.hellos.Add(1)
|
||||
if h.hold.Load() {
|
||||
for end := time.Now().Add(barrierWait); h.hellos.Load() < h.together.Load() && time.Now().Before(end); {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
_ = c.WriteControl(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(laneHeldCloseCode, "this bastion is quarantined"), time.Now().Add(time.Second))
|
||||
return
|
||||
@@ -66,6 +73,9 @@ func startHeldHost(t *testing.T) *heldHost {
|
||||
return h
|
||||
}
|
||||
|
||||
// barrierWait bounds how long a held socket waits for the rest of its pool.
|
||||
const barrierWait = 15 * time.Second
|
||||
|
||||
// shortHolds makes the holds short enough for a test. Set before any worker
|
||||
// runs, and never from a parallel subtest.
|
||||
func shortHolds(t *testing.T, first, max time.Duration) {
|
||||
@@ -109,6 +119,17 @@ func lanePools(dir string) map[string]func(ctx context.Context, host string, h *
|
||||
// worker of any lane dials it again before the hold is over: one hello per
|
||||
// worker, where a pool used to dial again at once or a second later, 30 to 80
|
||||
// times a second from one held machine.
|
||||
//
|
||||
// The Host closes the sockets only once every worker has said hello
|
||||
// (together). Closing the first at once made the test race the pool's own
|
||||
// start: a worker not yet past its first waitClear when another was refused
|
||||
// rightly waited out the hold before dialling, and the count read one of two
|
||||
// (a full gate, 2026-11-03). The hold is timed from the refusals, which come
|
||||
// after the last hello, so the 2 s look is inside the 3 s hold however slowly
|
||||
// the workers start.
|
||||
//
|
||||
// MUTATION: drop hold.refused from a lane's worker (mgmt.go). RESULT: red,
|
||||
// that lane dials again within the hold.
|
||||
func TestEveryLanePoolWaitsOutAHostThatHoldsThisMachine(t *testing.T) {
|
||||
shortHolds(t, 3*time.Second, time.Minute)
|
||||
for lane, run := range lanePools(t.TempDir()) {
|
||||
@@ -117,10 +138,17 @@ func TestEveryLanePoolWaitsOutAHostThatHoldsThisMachine(t *testing.T) {
|
||||
host := "held-" + lane + ".example.test"
|
||||
ownHold(t, host)
|
||||
h := startHeldHost(t)
|
||||
const n = 2
|
||||
h.together.Store(n)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
const n = 2
|
||||
go run(ctx, host, h, n)
|
||||
for end := time.Now().Add(barrierWait); h.hellos.Load() < n && time.Now().Before(end); {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := h.hellos.Load(); got < n {
|
||||
t.Fatalf("only %d of %d workers dialled the Host in %s", got, n, barrierWait)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
if got := h.hellos.Load(); got != n {
|
||||
t.Fatalf("%d dials within the hold from %d workers; want one each", got, n)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
// llmlocal.go — finding the OpenAI-compatible model server on this machine
|
||||
// when PRIVATAE_LLM_ADDR names none.
|
||||
//
|
||||
// Before 0.63.0 that lane existed only when someone set the variable in the
|
||||
// bastion's environment, which no installer does, so a machine running
|
||||
// llama.cpp on its usual port served no model to its Host. Now, with the
|
||||
// variable unset, the bastion looks on this machine's loopback, and nowhere
|
||||
// else, at the ports the common servers
|
||||
// listen on by default: 127.0.0.1:8080 (llama.cpp's llama-server), :8000
|
||||
// (vLLM), :1234 (LM Studio), in that order. Each is asked GET /v1/models with a
|
||||
// short timeout, and the first whose answer is OpenAI-shaped (a "data" list of
|
||||
// models, each with an "id") is the server. Something else answering on one of
|
||||
// those ports (a web app on 8080, say) is not taken for a model server, nor,
|
||||
// on Linux, a server another account runs (llmListenerTrusted).
|
||||
//
|
||||
// The answer is kept and asked again every llmReprobeEvery, and at once after
|
||||
// it failed (llmTargetFailed: the beat's probe of it, a dial of it), so the
|
||||
// heartbeat that advertises the lane and the forward that serves it read the
|
||||
// same address: both call llmTarget (env.go), the one function for the lane.
|
||||
// The variable, when set, overrides all of this, as before. What was found, or
|
||||
// that nothing was, is logged once per change and shown by `status` and
|
||||
// `doctor`.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// llmCandidate is one place a local OpenAI-compatible server listens by
|
||||
// default. Loopback only, by construction: the addresses are fixed here.
|
||||
type llmCandidate struct {
|
||||
Addr string
|
||||
Server string // whose default port it is, for the words
|
||||
}
|
||||
|
||||
var llmCandidates = []llmCandidate{
|
||||
{"127.0.0.1:8080", "llama.cpp"},
|
||||
{"127.0.0.1:8000", "vLLM"},
|
||||
{"127.0.0.1:1234", "LM Studio"},
|
||||
}
|
||||
|
||||
const (
|
||||
llmReprobeEvery = 60 * time.Second
|
||||
llmProbeTimeout = 800 * time.Millisecond
|
||||
)
|
||||
|
||||
// llmProbe asks addr for its models; ok only for an OpenAI-shaped answer. A
|
||||
// seam for the suite.
|
||||
var llmProbe = probeOpenAIShaped
|
||||
|
||||
// llmTrusted: the program on that port may be handed the Host's prompts
|
||||
// (llmlocal_linux.go: root's, this bastion's user's or the owner's). A seam.
|
||||
var llmTrusted = llmListenerTrusted
|
||||
|
||||
// probeOpenAIShaped is GET http://addr/v1/models: a 200 whose body is
|
||||
// {"data": [{"id": "..."}, ...]} with at least one model and an id on each.
|
||||
func probeOpenAIShaped(addr string) ([]string, bool) {
|
||||
cl := &http.Client{Timeout: llmProbeTimeout}
|
||||
resp, err := cl.Get("http://" + addr + "/v1/models")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, false
|
||||
}
|
||||
return openAIShapedModels(io.LimitReader(resp.Body, 1<<20))
|
||||
}
|
||||
|
||||
// openAIShapedModels reads an OpenAI /v1/models answer: the model ids, and
|
||||
// whether it is one.
|
||||
func openAIShapedModels(r io.Reader) ([]string, bool) {
|
||||
var list struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(r).Decode(&list); err != nil || len(list.Data) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var ids []string
|
||||
for _, m := range list.Data {
|
||||
id, _ := m["id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, false
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
|
||||
// llmFound is what the bastion found on this machine.
|
||||
type llmFound struct {
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Server string `json:"server,omitempty"` // whose default port it answered on
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
var llmLocal = struct {
|
||||
sync.Mutex
|
||||
found llmFound
|
||||
at time.Time
|
||||
probed bool
|
||||
said string // what was last logged
|
||||
}{}
|
||||
|
||||
// discoveredLLM is the server found on this machine, asked again when the
|
||||
// last answer is older than llmReprobeEvery or was marked failed.
|
||||
func discoveredLLM() llmFound {
|
||||
llmLocal.Lock()
|
||||
defer llmLocal.Unlock()
|
||||
if llmLocal.probed && time.Since(llmLocal.at) < llmReprobeEvery {
|
||||
return llmLocal.found
|
||||
}
|
||||
f := llmFound{}
|
||||
for _, c := range llmCandidates {
|
||||
if models, ok := llmProbe(c.Addr); ok && llmTrusted(c.Addr) {
|
||||
f = llmFound{Addr: c.Addr, Server: c.Server, Models: models}
|
||||
break
|
||||
}
|
||||
}
|
||||
llmLocal.found, llmLocal.at, llmLocal.probed = f, time.Now(), true
|
||||
// Said in the running bastion's log, once per change; a command such as
|
||||
// `status` shows it in its own words instead.
|
||||
if line := llmFoundWords(f); line != llmLocal.said && daemonRunningHere() {
|
||||
llmLocal.said = line
|
||||
fmt.Printf("[llm] %s\n", line)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// daemonRunningHere: this process is the running bastion (cmdRun set up its
|
||||
// supervisor), not a command beside it.
|
||||
func daemonRunningHere() bool {
|
||||
supervisor.Lock()
|
||||
defer supervisor.Unlock()
|
||||
return supervisor.ctx != nil
|
||||
}
|
||||
|
||||
// llmTargetFailed marks the found server as failed when addr is it, so the
|
||||
// next llmTarget asks again rather than serve a server that went away.
|
||||
func llmTargetFailed(addr string) {
|
||||
if addr == "" || os.Getenv("PRIVATAE_LLM_ADDR") != "" {
|
||||
return
|
||||
}
|
||||
llmLocal.Lock()
|
||||
defer llmLocal.Unlock()
|
||||
if llmLocal.found.Addr == addr {
|
||||
llmLocal.probed = false
|
||||
}
|
||||
}
|
||||
|
||||
// llmCandidateList is the places looked, for the words.
|
||||
func llmCandidateList() string {
|
||||
var out []string
|
||||
for _, c := range llmCandidates {
|
||||
out = append(out, c.Addr+" ("+c.Server+")")
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func llmFoundWords(f llmFound) string {
|
||||
if f.Addr == "" {
|
||||
return "no OpenAI-compatible model server found on this machine (looked on " + llmCandidateList() +
|
||||
"); set PRIVATAE_LLM_ADDR to name another on this machine"
|
||||
}
|
||||
s := fmt.Sprintf("found an OpenAI-compatible model server on %s, %s's port", f.Addr, f.Server)
|
||||
if len(f.Models) > 0 {
|
||||
s += ": " + strings.Join(f.Models, ", ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// statusLLM is the model-server line `status` and `doctor` show.
|
||||
type statusLLM struct {
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Source string `json:"source"` // "PRIVATAE_LLM_ADDR", "found" or "none"
|
||||
Server string `json:"server,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
func llmStatus() *statusLLM {
|
||||
if v := os.Getenv("PRIVATAE_LLM_ADDR"); v != "" {
|
||||
return &statusLLM{Addr: v, Source: "PRIVATAE_LLM_ADDR"}
|
||||
}
|
||||
f := discoveredLLM()
|
||||
if f.Addr == "" {
|
||||
return &statusLLM{Source: "none"}
|
||||
}
|
||||
return &statusLLM{Addr: f.Addr, Source: "found", Server: f.Server, Models: f.Models}
|
||||
}
|
||||
|
||||
func (l *statusLLM) words() string {
|
||||
switch l.Source {
|
||||
case "PRIVATAE_LLM_ADDR":
|
||||
return l.Addr + " (set by PRIVATAE_LLM_ADDR)"
|
||||
case "found":
|
||||
return llmFoundWords(llmFound{Addr: l.Addr, Server: l.Server, Models: l.Models})
|
||||
}
|
||||
return llmFoundWords(llmFound{})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// llmListenerTrusted: the program listening on the loopback port addr names
|
||||
// runs as root, as this bastion's own user, or as the machine's recorded
|
||||
// owner. Found without anyone setting PRIVATAE_LLM_ADDR, a model server is
|
||||
// handed the Host's prompts, and any account on the machine can listen on
|
||||
// 127.0.0.1:8080 while llama.cpp is not running; a server of another account
|
||||
// is not taken (the variable still names one on purpose). The kernel's own
|
||||
// table says who owns each listening socket (/proc/net/tcp, tcp6).
|
||||
func llmListenerTrusted(addr string) bool {
|
||||
port := portOfAddr(addr)
|
||||
if port <= 0 {
|
||||
return false
|
||||
}
|
||||
var uids []uint32
|
||||
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
||||
if raw, err := os.ReadFile(f); err == nil {
|
||||
uids = append(uids, loopbackListenerUIDs(string(raw), port)...)
|
||||
}
|
||||
}
|
||||
owner, _, ownerKnown, _ := ownerAccountLoad()
|
||||
return listenersTrusted(uids, uint32(os.Getuid()), owner, ownerKnown)
|
||||
}
|
||||
|
||||
// listenersTrusted: there is a listener, and every one is root's, self's or
|
||||
// the owner's.
|
||||
func listenersTrusted(uids []uint32, self, owner uint32, ownerKnown bool) bool {
|
||||
if len(uids) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, u := range uids {
|
||||
if u != 0 && u != self && (!ownerKnown || u != owner) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// loopbackListenerUIDs reads a /proc/net/tcp table: the uid of every socket
|
||||
// LISTENing on port at a loopback or any-address local address.
|
||||
func loopbackListenerUIDs(table string, port int) []uint32 {
|
||||
var out []uint32
|
||||
for _, line := range strings.Split(table, "\n")[1:] {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 8 || f[3] != "0A" {
|
||||
continue
|
||||
}
|
||||
host, p, ok := strings.Cut(f[1], ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseUint(p, 16, 16); err != nil || int(n) != port {
|
||||
continue
|
||||
}
|
||||
switch host {
|
||||
case "0100007F", "00000000", "00000000000000000000000001000000", "00000000000000000000000000000000":
|
||||
default:
|
||||
if !strings.HasSuffix(host, "7F") || len(host) != 8 { // 127.x.y.z, little-endian
|
||||
continue
|
||||
}
|
||||
}
|
||||
if u, err := strconv.ParseUint(f[7], 10, 32); err == nil {
|
||||
out = append(out, uint32(u))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Found without PRIVATAE_LLM_ADDR, a model server another account runs on the
|
||||
// port is not handed the Host's prompts; root's, this bastion's and its own
|
||||
// listener are.
|
||||
//
|
||||
// MUTATION: accept any uid in listenersTrusted. RESULT: red.
|
||||
func TestAModelServerOfAnotherAccountIsNotTaken(t *testing.T) {
|
||||
table := " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" +
|
||||
" 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 1 1 0 100 0 0 10 0\n" +
|
||||
" 1: 0100007F:1F40 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n" +
|
||||
" 2: 0100007F:1F40 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1002 0 3 1 0 100 0 0 10 0\n"
|
||||
if got := loopbackListenerUIDs(table, 8080); len(got) != 1 || got[0] != 1001 {
|
||||
t.Errorf("8080: %v", got)
|
||||
}
|
||||
if got := loopbackListenerUIDs(table, 8000); len(got) != 1 || got[0] != 0 {
|
||||
t.Errorf("8000 (an established connection is not a listener): %v", got)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
uids []uint32
|
||||
owner bool
|
||||
want bool
|
||||
}{
|
||||
{[]uint32{0}, false, true}, {[]uint32{1000}, false, true}, {[]uint32{1001}, true, true},
|
||||
{[]uint32{1001}, false, false}, {[]uint32{1002}, true, false}, {[]uint32{0, 1002}, true, false}, {nil, true, false},
|
||||
} {
|
||||
if got := listenersTrusted(c.uids, 1000, 1001, c.owner); got != c.want {
|
||||
t.Errorf("listeners %v (owner recorded %v): trusted %v, want %v", c.uids, c.owner, got, c.want)
|
||||
}
|
||||
}
|
||||
// A listener of this process's own is trusted, a port nobody listens on is not.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
if !llmListenerTrusted(ln.Addr().String()) {
|
||||
t.Errorf("this process's own listener (uid %d) is not trusted", os.Getuid())
|
||||
}
|
||||
_, p, _ := net.SplitHostPort(ln.Addr().String())
|
||||
n, _ := strconv.Atoi(p)
|
||||
_ = ln.Close()
|
||||
if llmListenerTrusted("127.0.0.1:" + strconv.Itoa(n)) {
|
||||
t.Error("a port nobody listens on is trusted")
|
||||
}
|
||||
// Discovery skips an untrusted server for the next one.
|
||||
bad, good := openAIServer(t, oneModel), openAIServer(t, oneModel)
|
||||
withLLMCandidates(t, bad, good)
|
||||
prev := llmTrusted
|
||||
llmTrusted = func(a string) bool { return a != bad }
|
||||
t.Cleanup(func() { llmTrusted = prev })
|
||||
if got := llmTarget(); got != good {
|
||||
t.Errorf("llmTarget = %q, want the trusted one", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
// llmListenerTrusted is Linux's check of who listens (llmlocal_linux.go). On
|
||||
// Windows and macOS the found server is taken as it answers; who owns the
|
||||
// port is not checked there yet.
|
||||
func llmListenerTrusted(string) bool { return true }
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// withLLMCandidates points the discovery at addrs (in order) with the real
|
||||
// probe, and starts from no answer.
|
||||
func withLLMCandidates(t *testing.T, addrs ...string) {
|
||||
t.Helper()
|
||||
prevC, prevP := llmCandidates, llmProbe
|
||||
llmCandidates = nil
|
||||
for i, a := range addrs {
|
||||
llmCandidates = append(llmCandidates, llmCandidate{Addr: a, Server: []string{"llama.cpp", "vLLM", "LM Studio"}[i%3]})
|
||||
}
|
||||
llmProbe = probeOpenAIShaped
|
||||
resetLLMDiscovery()
|
||||
t.Cleanup(func() { llmCandidates, llmProbe = prevC, prevP; resetLLMDiscovery() })
|
||||
t.Setenv("PRIVATAE_LLM_ADDR", "")
|
||||
}
|
||||
|
||||
func resetLLMDiscovery() {
|
||||
llmLocal.Lock()
|
||||
llmLocal.found, llmLocal.probed, llmLocal.said = llmFound{}, false, ""
|
||||
llmLocal.Unlock()
|
||||
}
|
||||
|
||||
// openAIServer answers /v1/models with body.
|
||||
func openAIServer(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return strings.TrimPrefix(srv.URL, "http://")
|
||||
}
|
||||
|
||||
// deadAddr is a loopback port nothing listens on.
|
||||
func deadAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
return a
|
||||
}
|
||||
|
||||
const oneModel = `{"object":"list","data":[{"id":"qwen3-4b-q4_k_m.gguf","object":"model"}]}`
|
||||
|
||||
// None answering: no lane, and status says where it looked.
|
||||
//
|
||||
// MUTATION: return the first candidate whatever it answers. RESULT: red.
|
||||
func TestNoLocalModelServerMeansNoLane(t *testing.T) {
|
||||
withLLMCandidates(t, deadAddr(t), deadAddr(t))
|
||||
if got := llmTarget(); got != "" {
|
||||
t.Fatalf("llmTarget = %q with nothing answering", got)
|
||||
}
|
||||
if ok, _ := openAICompatModels(); ok {
|
||||
t.Error("the beat would advertise a model server that is not there")
|
||||
}
|
||||
if l := llmStatus(); l.Source != "none" || !strings.Contains(l.words(), "no OpenAI-compatible model server found") {
|
||||
t.Errorf("status: %+v %q", l, l.words())
|
||||
}
|
||||
}
|
||||
|
||||
// One answering: the lane names it, the beat's probe and the forward agree,
|
||||
// and status says which server it is.
|
||||
//
|
||||
// MUTATION: have llmTarget return only the variable. RESULT: red.
|
||||
func TestALocalModelServerIsFound(t *testing.T) {
|
||||
a := openAIServer(t, oneModel)
|
||||
withLLMCandidates(t, deadAddr(t), a)
|
||||
if got := llmTarget(); got != a {
|
||||
t.Fatalf("llmTarget = %q, want %q", got, a)
|
||||
}
|
||||
ok, models := openAICompatModels()
|
||||
if !ok || strings.Join(models, ",") != "qwen3-4b-q4_k_m.gguf" {
|
||||
t.Errorf("the beat's probe: %v %v", ok, models)
|
||||
}
|
||||
if s, _ := serveServiceNamed("llm"); s.target() != a {
|
||||
t.Error("the serve lane reads another address than the beat")
|
||||
}
|
||||
l := llmStatus()
|
||||
if l.Source != "found" || l.Addr != a || !strings.Contains(l.words(), "found an OpenAI-compatible model server on "+a) {
|
||||
t.Errorf("status: %+v %q", l, l.words())
|
||||
}
|
||||
raw, _ := json.Marshal(statusRead{LLM: l})
|
||||
if !strings.Contains(string(raw), `"source":"found"`) {
|
||||
t.Errorf("status --json: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Several answering: the first in the order wins (llama.cpp, vLLM, LM Studio).
|
||||
//
|
||||
// MUTATION: keep looking after the first answer and take the last. RESULT: red.
|
||||
func TestTheFirstLocalModelServerInOrderWins(t *testing.T) {
|
||||
first, second := openAIServer(t, oneModel), openAIServer(t, `{"data":[{"id":"other"}]}`)
|
||||
withLLMCandidates(t, first, second)
|
||||
if got := llmTarget(); got != first {
|
||||
t.Fatalf("llmTarget = %q, want the first, %q", got, first)
|
||||
}
|
||||
}
|
||||
|
||||
// The variable overrides what is found.
|
||||
//
|
||||
// MUTATION: look before reading the variable. RESULT: red.
|
||||
func TestTheVariableOverridesTheLocalModelServer(t *testing.T) {
|
||||
a := openAIServer(t, oneModel)
|
||||
withLLMCandidates(t, a)
|
||||
t.Setenv("PRIVATAE_LLM_ADDR", "127.0.0.1:18080")
|
||||
if got := llmTarget(); got != "127.0.0.1:18080" {
|
||||
t.Fatalf("llmTarget = %q", got)
|
||||
}
|
||||
if l := llmStatus(); l.Source != "PRIVATAE_LLM_ADDR" {
|
||||
t.Errorf("status: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
// Something else on the port (a web app, a server whose list is not OpenAI's)
|
||||
// is not taken for a model server.
|
||||
//
|
||||
// MUTATION: accept any 200 in probeOpenAIShaped. RESULT: red.
|
||||
func TestAnAnswerThatIsNotOpenAIShapedIsIgnored(t *testing.T) {
|
||||
for _, body := range []string{`<html>hello</html>`, `{"models":[{"name":"x"}]}`, `{"data":[]}`, `{"data":[{"name":"x"}]}`, `{"data":"x"}`} {
|
||||
bad := openAIServer(t, body)
|
||||
good := openAIServer(t, oneModel)
|
||||
withLLMCandidates(t, bad, good)
|
||||
if got := llmTarget(); got != good {
|
||||
t.Errorf("%s: llmTarget = %q, want the OpenAI-shaped one", body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only loopback is ever asked.
|
||||
//
|
||||
// MUTATION: add a LAN address to llmCandidates. RESULT: red.
|
||||
func TestOnlyLoopbackIsAskedForAModelServer(t *testing.T) {
|
||||
want := []string{"127.0.0.1:8080", "127.0.0.1:8000", "127.0.0.1:1234"}
|
||||
if len(llmCandidates) != len(want) {
|
||||
t.Fatalf("%+v", llmCandidates)
|
||||
}
|
||||
for i, c := range llmCandidates {
|
||||
if c.Addr != want[i] || !loopbackAddr(c.Addr) {
|
||||
t.Errorf("candidate %d is %q", i, c.Addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A found server that goes away is asked for again at once after a failure,
|
||||
// not served for the rest of the interval; one that starts later is found at
|
||||
// the next interval.
|
||||
//
|
||||
// MUTATION: drop llmTargetFailed from openAICompatModels. RESULT: red.
|
||||
func TestAModelServerThatWentAwayIsAskedForAgain(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(oneModel)) }))
|
||||
a := strings.TrimPrefix(srv.URL, "http://")
|
||||
withLLMCandidates(t, a)
|
||||
if llmTarget() != a {
|
||||
t.Fatal("not found")
|
||||
}
|
||||
srv.Close()
|
||||
if ok, _ := openAICompatModels(); ok {
|
||||
t.Fatal("a closed server answered")
|
||||
}
|
||||
if got := llmTarget(); got != "" {
|
||||
t.Errorf("after a failed probe llmTarget still names %q", got)
|
||||
}
|
||||
// Later: the cached "none" is asked again once the interval is over.
|
||||
b := openAIServer(t, oneModel)
|
||||
llmCandidates = []llmCandidate{{Addr: b, Server: "llama.cpp"}}
|
||||
llmLocal.Lock()
|
||||
llmLocal.at = time.Now().Add(-llmReprobeEvery - time.Second)
|
||||
llmLocal.Unlock()
|
||||
if got := llmTarget(); got != b {
|
||||
t.Errorf("a server that started later is not found: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -423,17 +423,30 @@ func startLaneHost(t *testing.T, sock string, cert tls.Certificate, clientCA *te
|
||||
// socket, or a Host's lane port (local_host_tcp_linux_test.go).
|
||||
func startLaneHostOn(t *testing.T, raw net.Listener, cert tls.Certificate, clientCA *testCA) *laneHost {
|
||||
t.Helper()
|
||||
h, mux, tc := newLaneHost(cert, clientCA)
|
||||
ln := tls.NewListener(countingListener{raw, &h.accepted}, tc)
|
||||
srv := &http.Server{Handler: mux, ErrorLog: log.New(io.Discard, "", 0)}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
return h
|
||||
}
|
||||
|
||||
// newLaneHost is a lane Host's routes and its TLS server configuration,
|
||||
// served on a listener (startLaneHostOn) or over connections it opens itself
|
||||
// (hostdoor_linux_test.go). It speaks the lane protocol name a Host that
|
||||
// connects to a server chooses (laneALPN); a bastion that dials offers none.
|
||||
func newLaneHost(cert tls.Certificate, clientCA *testCA) (*laneHost, http.Handler, *tls.Config) {
|
||||
h := &laneHost{jobs: make(chan []byte, 4), replies: make(chan string, 4)}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(clientCA.cert)
|
||||
ln := tls.NewListener(countingListener{raw, &h.accepted}, &tls.Config{Certificates: []tls.Certificate{cert},
|
||||
tc := &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{laneALPN},
|
||||
ClientCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
h.mu.Lock()
|
||||
h.sni = append(h.sni, cs.ServerName)
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}})
|
||||
}}
|
||||
up := websocket.Upgrader{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/network/bastions/heartbeat", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -496,10 +509,7 @@ func startLaneHostOn(t *testing.T, raw net.Listener, cert tls.Certificate, clien
|
||||
for _, p := range []string{"/bastion-harness", "/bastion-mgmt", "/bastion-egress", "/bastion-serve"} {
|
||||
mux.HandleFunc(p, ws)
|
||||
}
|
||||
srv := &http.Server{Handler: mux, ErrorLog: log.New(io.Discard, "", 0)}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
return h
|
||||
return h, mux, tc
|
||||
}
|
||||
|
||||
func (h *laneHost) snapshot() (hellos, beats []string) {
|
||||
|
||||
@@ -253,6 +253,7 @@ func takeHostFlag(args []string) (string, []string) {
|
||||
|
||||
const pairUsage = "usage: privatae-bastion pair [--replace] [--redeem-url unix:///<socket> | --redeem-url https://<host>:<port>/<path> --host-ca <pem file>] <token> | -\n" +
|
||||
" privatae-bastion pair <pairing code skp_…> | -\n" +
|
||||
" privatae-bastion pair --open [--port 8812] (on a server: your Host connects to it with the code this shows)\n" +
|
||||
" - reads the token or pairing code from standard input, so it stays out of the process list and shell history\n" +
|
||||
" exit status: 0 paired, 3 already paired with this Host (nothing changed), anything else a failure"
|
||||
|
||||
@@ -315,6 +316,16 @@ func main() {
|
||||
case "service":
|
||||
err = cmdService(os.Args[2:])
|
||||
case "pair":
|
||||
// pair --open: a pairing window for a Host that connects to this
|
||||
// server (pair_window.go).
|
||||
if addr, open, oerr := parsePairOpenArgs(os.Args[2:]); open {
|
||||
if oerr != nil {
|
||||
fmt.Fprintln(os.Stderr, oerr)
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdPairOpen(addr, os.Stdout)
|
||||
break
|
||||
}
|
||||
token, redeemURL, hostCA, replace, perr := parsePairArgs(os.Args[2:])
|
||||
if perr == nil {
|
||||
token, perr = pairToken(token, os.Stdin, stdinIsConsole(), os.Stderr)
|
||||
|
||||
@@ -59,6 +59,10 @@ func TestMain(m *testing.M) {
|
||||
if len(os.Args) >= 3 && os.Args[1] == opActionChildSubcommand {
|
||||
os.Exit(opActionChildMain(os.Args[2]))
|
||||
}
|
||||
// The suite never asks the build machine's own loopback for a model
|
||||
// server (llmlocal.go): a llama.cpp left running on 8080 there would make
|
||||
// every lane test depend on it. llmlocal_test.go sets its own.
|
||||
llmProbe = func(string) ([]string, bool) { return nil, false }
|
||||
if len(os.Args) >= 3 && os.Args[1] == opActionProbeSubcommand {
|
||||
os.Exit(opActionProbeMain(os.Args[2]))
|
||||
}
|
||||
@@ -138,6 +142,14 @@ func TestMain(m *testing.M) {
|
||||
// stops every op unit its names match: the suite's names are its own, so
|
||||
// it never stops an op the machine's own bastion is running.
|
||||
opIsolateOpsUnits()
|
||||
// THE FIREWALL IS THE MACHINE'S. A door the suite binds asks for its own
|
||||
// inbound rule (doorfw.go): never in the firewall of the machine running
|
||||
// the suite. The firewall tests stand their own firewalls in.
|
||||
doorFirewallFor = func(string) (doorFirewall, string) { return nil, "" }
|
||||
doorFirewallByID = func(string) doorFirewall { return nil }
|
||||
doorFWExec = func(name string, _ ...string) (string, error) {
|
||||
return "", fmt.Errorf("the suite never runs the machine's firewall (%s)", name)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
opIsolatedOpsUnitsDone()
|
||||
|
||||
+1
-5
@@ -340,11 +340,7 @@ func refuseMgmt(ws *websocket.Conn, why string) {
|
||||
// the identity the pairing_* ops are gated on.
|
||||
func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls.Config,
|
||||
dialHost func(ctx context.Context) (net.Conn, error), bearer, ctxRoot, dcnDir, backupRoot string, n int) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
hold := laneHoldFor(self)
|
||||
|
||||
+1
-5
@@ -502,11 +502,7 @@ const mirrorForwardMaxConns = 8
|
||||
// inventing a framing layer for no gain.
|
||||
func serveMirrorForward(ctx context.Context, gw, subnet, wsURL string,
|
||||
tlsCfg *tls.Config, dialHost func(ctx context.Context) (net.Conn, error), bearer string) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
var allowed *net.IPNet
|
||||
if subnet != "" {
|
||||
if _, n, err := net.ParseCIDR(subnet); err == nil {
|
||||
|
||||
+3
-2
@@ -129,8 +129,9 @@ func cmdSetMode(mode string) error {
|
||||
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.Printf(" Next: anchor this machine to your host — %s\n", anchorCommandHint())
|
||||
fmt.Println(" Every host paired after the anchor waits, pending, until the anchor host approves it.")
|
||||
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
|
||||
}
|
||||
|
||||
+101
-52
@@ -195,16 +195,18 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi
|
||||
if boxMode() == modeLocal && len(cfgs) > 0 {
|
||||
return nil, "", &localSecondHostError{serving: cfgs[0].HostName}
|
||||
}
|
||||
return pairPrecheckState(forceActive)
|
||||
return pairPrecheckState(forceActive, handle)
|
||||
}
|
||||
|
||||
// pairPrecheckState is the state a new pairing gets once pairPrecheck's walls
|
||||
// are passed: active for claim-anchor, pending behind an anchor, else "".
|
||||
func pairPrecheckState(forceActive bool) (*Config, string, error) {
|
||||
// are passed: active for claim-anchor, pending behind an anchor (or, on a
|
||||
// machine an organisation's policy governs and no Host holds yet, behind the
|
||||
// Host the policy names: anchor_local.go), else "".
|
||||
func pairPrecheckState(forceActive bool, handle string) (*Config, string, error) {
|
||||
if forceActive {
|
||||
return nil, pairingActive, nil
|
||||
}
|
||||
if currentAnchor() != nil {
|
||||
if currentAnchor() != nil || pendingUnderPolicy(handle) {
|
||||
return nil, pairingPending, nil
|
||||
}
|
||||
return nil, "", nil
|
||||
@@ -280,29 +282,29 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A networked machine is a server. Left unanchored it refuses every op to
|
||||
// every host (capsAllowIn: unanchoredNetworkedRefusal) until someone runs
|
||||
// claim-anchor, a step people missed. So the first pairing on a networked
|
||||
// box with no anchor anchors it, as `claim-anchor` with no token would, and
|
||||
// from then on every terminal and write waits for the person's approval and
|
||||
// every later pairing for this host's. Only with machine authority, which
|
||||
// claim-anchor needs anyway.
|
||||
// A networked machine is a server, with one Host in charge of it: the
|
||||
// anchor (anchor.go). Left unanchored it refuses every op to every host
|
||||
// (capsAllowIn: unanchoredNetworkedRefusal) until someone runs
|
||||
// claim-anchor, a step people missed. So with machine authority, which
|
||||
// claim-anchor needs anyway, `pair` puts the right Host in charge by
|
||||
// itself (anchor_local.go anchorAfterPair): the Host an organisation's
|
||||
// policy names, else a Host on this machine, else the first Host paired.
|
||||
// The pairing itself succeeded, so a failed anchor is reported, not
|
||||
// returned: `pair` failing here would read as "could not pair" to a caller
|
||||
// (npx cseries exits on it) when the pairing exists. claim-anchor, which
|
||||
// such a caller runs next, reports the anchor's own failure.
|
||||
anchored, err := anchorFirstNetworkedPairing(cfg, all)
|
||||
took, err := anchorAfterPair(cfg, all)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
}
|
||||
if anchored {
|
||||
if took.Taken {
|
||||
if fresh, lerr := loadPairings(); lerr == nil {
|
||||
if c := pairingFor(fresh, cfg.HostName); c != nil {
|
||||
cfg, all = c, fresh
|
||||
}
|
||||
}
|
||||
}
|
||||
pairReport(os.Stdout, cfg, all, anchored)
|
||||
pairReport(os.Stdout, cfg, all, took)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -327,45 +329,77 @@ func pairOptsFor(token, redeemURL, hostCAFile string, replace bool) (string, pai
|
||||
}
|
||||
|
||||
// pairReport is what `pair` prints once the pairing stands. Whatever the form
|
||||
// (anchored just now, over TCP, over a unix socket, pending, or over the
|
||||
// tunnel), it ends by saying to enrol a second factor when this pairing has
|
||||
// none and no approval device is pinned: without one, a local machine installs
|
||||
// a folder grant on the host's signed grant alone, and an anchored one has no
|
||||
// way to approve anything the host asks for.
|
||||
func pairReport(w io.Writer, cfg *Config, all []*Config, anchored bool) {
|
||||
// (put in charge just now, over TCP, over a unix socket, waiting to be
|
||||
// admitted, or over the tunnel), it ends by saying to enrol a second factor
|
||||
// when this pairing has none and no approval device is pinned: without one, a
|
||||
// local machine installs a folder grant on the host's signed grant alone, and
|
||||
// an anchored one has no way to approve anything the host asks for.
|
||||
//
|
||||
// It never tells the person to start a bastion that is already running: a
|
||||
// running bastion serves a new pairing by itself within supervisePeriod
|
||||
// (run.go superviseLoop), and on a machine whose bastion is a system service
|
||||
// the command that starts it is the service manager's (pairServeNote).
|
||||
func pairReport(w io.Writer, cfg *Config, all []*Config, took anchorTake) {
|
||||
enrol := ""
|
||||
if !secondFactorAvailable(cfg.dcnDir()) {
|
||||
enrol = stepupEnrollHint(cfg.HostName, len(all))
|
||||
}
|
||||
defer func() { pairEnrolNote(w, cfg.HostName, enrol) }()
|
||||
if anchored {
|
||||
fmt.Fprintf(w, "\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName)
|
||||
fmt.Fprintln(w, "waits for your approval, and any later host waits for this one to approve it.")
|
||||
if took.Taken {
|
||||
fmt.Fprintf(w, "\n%s is now the Host in charge of this machine: %s.\n", cfg.HostName, took.Why)
|
||||
if took.Replaced != "" {
|
||||
fmt.Fprintf(w, "%s was in charge before. It is told so, and waits for %s to admit it, as any other Host does.\n",
|
||||
took.Replaced, cfg.HostName)
|
||||
}
|
||||
if others := without(took.Waiting, took.Replaced); len(others) > 0 {
|
||||
fmt.Fprintf(w, "Waiting for %s to admit them: %s.\n", cfg.HostName, strings.Join(others, ", "))
|
||||
}
|
||||
fmt.Fprintln(w, "Every terminal and every write waits for your approval, and any other Host waits")
|
||||
fmt.Fprintf(w, "until %s admits it: %s asks you to approve it.\n", cfg.HostName, cfg.HostName)
|
||||
if enrol != "" {
|
||||
fmt.Fprintf(w, "Enrol your second factor now, so you can approve here:\n %s\n\n", enrol)
|
||||
enrol = ""
|
||||
}
|
||||
}
|
||||
if cfg.tcpTransport() && cfg.active() {
|
||||
if !cfg.active() {
|
||||
who := admitter()
|
||||
if who == "" {
|
||||
fmt.Fprintf(w, "Paired with %s — PENDING. Nothing runs for it until it is admitted.\n", cfg.HostName)
|
||||
return
|
||||
}
|
||||
why := "the Host in charge of this computer"
|
||||
if p := currentAnchorPolicy(); p != nil && boxMode() == modeNetworked && policyNames(p, who) {
|
||||
why += ", named by an organisation's policy (" + p.Source + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "Paired with %s — WAITING for %s to admit it.\n\n", cfg.HostName, who)
|
||||
fmt.Fprintf(w, "The code was used: %s now lists this computer, but nothing runs for it yet (no lanes).\n", cfg.HostName)
|
||||
fmt.Fprintf(w, "%s is %s, and it admits every other Host:\n", who, why)
|
||||
fmt.Fprintf(w, "%s asks you to approve %s. Once you do, %s is served at once, with no restart.\n", who, cfg.HostName, cfg.HostName)
|
||||
fmt.Fprintf(w, "Until then `privatae-bastion status` shows it as waiting for %s.\n", who)
|
||||
return
|
||||
}
|
||||
if cfg.hostDials() {
|
||||
fmt.Fprintf(w, "Paired with %s. Your Host connects to this server; this server never connects to it.\n\n", cfg.HostName)
|
||||
fmt.Fprintf(w, " lanes: over the connections your Host opens to port %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n",
|
||||
doorPort(cfg.doorAddr()), tcpServerName)
|
||||
fmt.Fprintf(w, " firewall: this server needs TCP port %s reachable inbound; the bastion keeps its own rule for it in a firewall it manages "+
|
||||
"here (status shows it), and nothing on your Host's network needs opening\n", doorPort(cfg.doorAddr()))
|
||||
fmt.Fprintf(w, " local: http://%s\n\n%s\n", cfg.ListenAddr, pairServeNote())
|
||||
return
|
||||
}
|
||||
if cfg.tcpTransport() {
|
||||
fmt.Fprintf(w, "Paired with %s over TCP.\n\n", cfg.HostName)
|
||||
fmt.Fprintf(w, " lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
|
||||
fmt.Fprintf(w, " local: http://%s\n\nStart (or restart) the bastion:\n privatae-bastion run\n", cfg.ListenAddr)
|
||||
fmt.Fprintf(w, " local: http://%s\n\n%s\n", cfg.ListenAddr, pairServeNote())
|
||||
return
|
||||
}
|
||||
if cfg.unixTransport() && cfg.active() {
|
||||
if cfg.unixTransport() {
|
||||
fmt.Fprintf(w, "Paired with %s, a Host on this machine.\n\n", cfg.HostName)
|
||||
fmt.Fprintf(w, " lanes: %s (mutual TLS, the Host's CA pinned)\n coder runs: a socket per run in %s\n", cfg.LaneSocket, cfg.RunsDir)
|
||||
fmt.Fprintf(w, " local: http://%s\n\nStart (or restart) the bastion:\n privatae-bastion run\n", cfg.ListenAddr)
|
||||
fmt.Fprintf(w, " local: http://%s\n\n%s\n", cfg.ListenAddr, pairServeNote())
|
||||
return
|
||||
}
|
||||
if !cfg.active() {
|
||||
fmt.Fprintf(w, "Paired with %s — PENDING.\n\n", cfg.HostName)
|
||||
fmt.Fprintln(w, "This machine is anchored, so a new host waits until the anchor host approves it.")
|
||||
fmt.Fprintln(w, "Nothing is started for it yet: no tunnel, no lanes. It shows on the anchor host's")
|
||||
fmt.Fprintln(w, "Network app as a pending pairing; once approved the daemon opens its lanes by itself.")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Paired with %s.\n\nOne `privatae-bastion run` serves every pairing:\n", cfg.HostName)
|
||||
fmt.Fprintf(w, "Paired with %s.\n\nOne bastion serves every pairing:\n", cfg.HostName)
|
||||
for _, c := range all {
|
||||
// The listen address is the CLI's loopback control port for that host
|
||||
// (add-folder, git-credential). The local secret that guards it is read
|
||||
@@ -373,10 +407,37 @@ func pairReport(w io.Writer, cfg *Config, all []*Config, anchored bool) {
|
||||
// operator client to hand it to since the MCP relay was removed.
|
||||
fmt.Fprintf(w, "\n %s\n local: http://%s\n", c.HostName, c.ListenAddr)
|
||||
}
|
||||
fmt.Fprintf(w, "\nStart (or restart) the tunnels:\n privatae-bastion run\n")
|
||||
fmt.Fprintf(w, "\n%s\n", pairServeNote())
|
||||
fmt.Fprintf(w, "\ngit to your forge goes through this bastion — one-time setup:\n privatae-bastion git-setup\n")
|
||||
}
|
||||
|
||||
// pairDaemonRunning: a bastion is serving on this machine (its console
|
||||
// answers, or the system service runs, waiting for its first pairing). A seam
|
||||
// for the suite.
|
||||
var pairDaemonRunning = func() bool { return uiDaemonListening() || systemServiceRunning() }
|
||||
|
||||
// pairServeNote is `pair`'s last line about serving the new pairing: a running
|
||||
// bastion picks it up by itself; otherwise the command that starts this
|
||||
// machine's bastion (the service manager's on a service machine).
|
||||
func pairServeNote() string {
|
||||
if pairDaemonRunning() {
|
||||
return fmt.Sprintf("The bastion running on this machine serves it within %d seconds; nothing needs restarting.",
|
||||
int(supervisePeriod.Seconds()))
|
||||
}
|
||||
return "Start the bastion so it serves it:\n " + runCommandHint()
|
||||
}
|
||||
|
||||
// without is list less drop.
|
||||
func without(list []string, drop string) []string {
|
||||
var out []string
|
||||
for _, s := range list {
|
||||
if s != drop {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pairEnrolNote says what a pairing with no second factor means on this kind
|
||||
// of machine, and how to enrol one. hint "" says nothing.
|
||||
func pairEnrolNote(w io.Writer, host, hint string) {
|
||||
@@ -393,19 +454,6 @@ func pairEnrolNote(w io.Writer, host, hint string) {
|
||||
"sessions are refused. Enrol it now, so each needs a code from this machine:\n %s\n", host, hint)
|
||||
}
|
||||
|
||||
// anchorFirstNetworkedPairing anchors a networked machine that has no anchor
|
||||
// to the pairing just made, when it is the machine's only one and the caller
|
||||
// holds machine authority (see cmdPair). Reports whether it anchored.
|
||||
func anchorFirstNetworkedPairing(cfg *Config, all []*Config) (bool, error) {
|
||||
if boxMode() != modeNetworked || currentAnchor() != nil || len(all) != 1 || !machineAuthorityHeld() {
|
||||
return false, nil
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", cfg.HostName); err != nil {
|
||||
return false, fmt.Errorf("paired with %s, but anchoring this networked machine to it failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint())
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// redeemURLBase is the platform's enrollment ingress. A variable so a test can
|
||||
// serve a redeem response on loopback; nothing outside tests assigns it.
|
||||
var redeemURLBase = "https://api.privatae.ai"
|
||||
@@ -509,7 +557,7 @@ func pairWith(token string, opts pairOpts) (*Config, []*Config, error) {
|
||||
if !yes {
|
||||
return nil, nil, fmt.Errorf("nothing was paired: %s stays the only Host this computer serves", second.serving)
|
||||
}
|
||||
existing, state, err = pairPrecheckState(forceActive)
|
||||
existing, state, err = pairPrecheckState(forceActive, handle)
|
||||
}
|
||||
// The CA this pair trusts, as a fingerprint: the pairing code's, or that
|
||||
// of the --host-ca file. "" for a platform or unix-socket pairing, whose
|
||||
@@ -738,8 +786,9 @@ func commitPairing(cfg, existing *Config, replace bool, state string) (*Config,
|
||||
// host renamed when its address moved). unpair() is deliberately not
|
||||
// reused: it refuses to remove the anchor's own pairing, which is exactly
|
||||
// the case here — the anchor is re-paired like every other host. The anchor
|
||||
// RECORD is untouched by design; anchor.json still names the old host and
|
||||
// only `claim-anchor --rotate` may re-sign it.
|
||||
// RECORD is untouched here; anchor.json still names the old host until
|
||||
// `claim-anchor --repin` re-signs it, or, for a Host paired again through a
|
||||
// window under its pinned CA, carryAnchorToNewName (pair_window.go) does.
|
||||
if replace && existing != nil && existing.HostName != cfg.HostName {
|
||||
if err := removeStalePairingFile(existing.HostName); err != nil {
|
||||
return nil, nil, fmt.Errorf("paired as %s, but the superseded pairing file for %s "+
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||
box(t, true)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
cfg, all := only(t, tMember)
|
||||
anchored, err := anchorFirstNetworkedPairing(cfg, all)
|
||||
anchored, err := anchoredAfterPair(cfg, all)
|
||||
if err != nil || !anchored {
|
||||
t.Fatalf("the first pairing on a networked box must anchor it: anchored=%v err=%v", anchored, err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||
box(t, false)
|
||||
writePairing(t, tMember)
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
if anchored, err := anchoredAfterPair(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("a local machine must not be anchored: %v %v", anchored, err)
|
||||
}
|
||||
if currentAnchor() != nil {
|
||||
@@ -81,7 +81,7 @@ func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||
}
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
if anchored, err := anchoredAfterPair(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("an anchored machine must not be re-anchored: %v %v", anchored, err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
|
||||
@@ -98,7 +98,7 @@ func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||
writePairing(t, tAnchor)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
if anchored, err := anchoredAfterPair(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("with two pairings the person picks the anchor: %v %v", anchored, err)
|
||||
}
|
||||
})
|
||||
@@ -109,8 +109,14 @@ func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
held = false
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
if anchored, err := anchoredAfterPair(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("without machine authority nothing is anchored: %v %v", anchored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// anchoredAfterPair is anchorAfterPair's yes or no.
|
||||
func anchoredAfterPair(cfg *Config, all []*Config) (bool, error) {
|
||||
t, err := anchorAfterPair(cfg, all)
|
||||
return t.Taken, err
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func pairReportOf(cfg *Config, all []*Config, anchored bool) string {
|
||||
var b strings.Builder
|
||||
pairReport(&b, cfg, all, anchored)
|
||||
pairReport(&b, cfg, all, anchorTake{Taken: anchored, Why: "it is the first Host paired with this networked machine"})
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
+29
-14
@@ -219,20 +219,8 @@ func tcpAnswerRefusal(rr tcpRedeemResp, redeem *url.URL, hostCA, opKey string, e
|
||||
if !sameCAs(rr.caCert(), hostCA) {
|
||||
return errors.New("the Host answered with a CA other than the --host-ca it was paired with — refusing")
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(rr.operatorCert()), []byte(opKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert is not for this bastion's key: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert does not parse: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
for _, c := range pemCerts(hostCA) {
|
||||
pool.AddCert(c)
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert is not signed by its CA for client use: %v", err)
|
||||
if err := operatorCertRefusal(rr.operatorCert(), hostCA, opKey); err != nil {
|
||||
return err
|
||||
}
|
||||
// Again here: the pairing the answer's name lands on may not be the one
|
||||
// the token's handle found before the redeem.
|
||||
@@ -250,6 +238,27 @@ func tcpAnswerRefusal(rr tcpRedeemResp, redeem *url.URL, hostCA, opKey string, e
|
||||
return nil
|
||||
}
|
||||
|
||||
// operatorCertRefusal: the operator certificate a Host answered with is for
|
||||
// this bastion's key, and its CA signed it for client use.
|
||||
func operatorCertRefusal(certPEM, hostCA, opKey string) error {
|
||||
pair, err := tls.X509KeyPair([]byte(certPEM), []byte(opKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert is not for this bastion's key: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert does not parse: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
for _, c := range pemCerts(hostCA) {
|
||||
pool.AddCert(c)
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert is not signed by its CA for client use: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tcpPinRefusal is THE PIN, as for a unix pairing: a --replace keeps the CA
|
||||
// the pairing was made with. pairWithTokenVia asks it before the redeem
|
||||
// dials, because --host-ca is the only root that redeem trusts: under
|
||||
@@ -328,6 +337,9 @@ func tcpLaneDialer(cfg *Config) (func(ctx context.Context) (net.Conn, error), *t
|
||||
// localLaneDialer is the lane transport of a pairing with a Host that has no
|
||||
// platform: the lane socket or the lane port.
|
||||
func localLaneDialer(cfg *Config) (func(ctx context.Context) (net.Conn, error), *tls.Config, error) {
|
||||
if cfg.hostDials() {
|
||||
return hostDialsLaneDialer(cfg)
|
||||
}
|
||||
if cfg.tcpTransport() {
|
||||
return tcpLaneDialer(cfg)
|
||||
}
|
||||
@@ -336,6 +348,9 @@ func localLaneDialer(cfg *Config) (func(ctx context.Context) (net.Conn, error),
|
||||
|
||||
// laneWhere is where a local pairing's lanes go, for the log and status.
|
||||
func (c *Config) laneWhere() string {
|
||||
if c.hostDials() {
|
||||
return "connections your Host opens to this server's port " + doorPort(c.doorAddr())
|
||||
}
|
||||
if c.tcpTransport() {
|
||||
return c.LaneAddr
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,9 @@ func handlePairingGrantSync(ws *websocket.Conn, _ string, args []string) {
|
||||
return
|
||||
}
|
||||
anchorMu.Unlock()
|
||||
// A former anchor admitted again has heard it was replaced: its beats
|
||||
// stop saying so (anchor_local.go anchorReplacedNote).
|
||||
clearAnchorReplacedFor(c.HostName)
|
||||
// Open the lanes now: the user approved on the anchor host and expects the
|
||||
// member host to come up, not to wait for someone to restart the service.
|
||||
started := false
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
// pairing_retry.go — each pairing runs on its own.
|
||||
//
|
||||
// Until 0.63.0 one pairing's fatal error (a port it could not bind, a lane
|
||||
// that would not come up, a bad certificate in its file) went to cmdRun and
|
||||
// stopped the whole daemon, so every other Host on the machine went dark with
|
||||
// it, and on Windows the service manager restarted all of them into the same
|
||||
// failure every five seconds. Now that error stops that pairing alone. It is
|
||||
// reported (the log, the machine's events, `status`), and the supervisor
|
||||
// (run.go superviseOnce) starts it again by itself after a pause that doubles
|
||||
// from supervisePeriod up to retryMaxPause, the other pairings serving all
|
||||
// along. A run that stays up for retryResetAfter clears the record.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
retryMaxPause = 5 * time.Minute
|
||||
retryResetAfter = 2 * time.Minute
|
||||
)
|
||||
|
||||
// pairingFailure is one pairing's last fatal error, as status reads it.
|
||||
type pairingFailure struct {
|
||||
Error string `json:"error"`
|
||||
At string `json:"at"`
|
||||
NextTry string `json:"next_try"`
|
||||
Tries int `json:"tries"`
|
||||
}
|
||||
|
||||
var pairingRetry = struct {
|
||||
sync.Mutex
|
||||
next map[string]time.Time
|
||||
tries map[string]int
|
||||
}{next: map[string]time.Time{}, tries: map[string]int{}}
|
||||
|
||||
func pairingFailuresPath() (string, error) {
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(d, "pairing_failures.json"), nil
|
||||
}
|
||||
|
||||
// loadPairingFailures is the record status reads; empty when there is none.
|
||||
func loadPairingFailures() map[string]pairingFailure {
|
||||
out := map[string]pairingFailure{}
|
||||
p, err := pairingFailuresPath()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
if raw, err := os.ReadFile(p); err == nil {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func savePairingFailuresLocked(m map[string]pairingFailure) {
|
||||
p, err := pairingFailuresPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(m) == 0 {
|
||||
_ = os.Remove(p)
|
||||
return
|
||||
}
|
||||
if b, err := json.MarshalIndent(m, "", " "); err == nil {
|
||||
_ = atomicWrite(p, b)
|
||||
}
|
||||
}
|
||||
|
||||
// retryPause is the pause before try n+1 after n failures in a row.
|
||||
func retryPause(n int) time.Duration {
|
||||
d := supervisePeriod
|
||||
for i := 1; i < n && d < retryMaxPause; i++ {
|
||||
d *= 2
|
||||
}
|
||||
return min(d, retryMaxPause)
|
||||
}
|
||||
|
||||
// pairingFailed records host's fatal error and when it is tried again, and
|
||||
// says so. ranFor is how long the run had been up: one that served for
|
||||
// retryResetAfter starts the count again.
|
||||
func pairingFailed(host string, err error, ranFor time.Duration) {
|
||||
pairingRetry.Lock()
|
||||
defer pairingRetry.Unlock()
|
||||
if ranFor >= retryResetAfter {
|
||||
pairingRetry.tries[host] = 0
|
||||
}
|
||||
pairingRetry.tries[host]++
|
||||
n := pairingRetry.tries[host]
|
||||
now := time.Now()
|
||||
next := now.Add(retryPause(n))
|
||||
pairingRetry.next[host] = next
|
||||
m := loadPairingFailures()
|
||||
m[host] = pairingFailure{Error: err.Error(), At: now.UTC().Format(time.RFC3339),
|
||||
NextTry: next.UTC().Format(time.RFC3339), Tries: n}
|
||||
savePairingFailuresLocked(m)
|
||||
why := strings.TrimRight(strings.TrimSpace(err.Error()), ".")
|
||||
line := fmt.Sprintf("stopped: %s. The other Hosts go on being served; it is tried again by itself at %s.",
|
||||
why, next.Local().Format("15:04:05"))
|
||||
fmt.Printf("[%s] %s\n", host, line)
|
||||
recordLocalEvent("pairing", host+" "+line)
|
||||
}
|
||||
|
||||
// pairingRetryDue: host has no failure pending a pause, or its pause is over.
|
||||
func pairingRetryDue(host string) bool {
|
||||
pairingRetry.Lock()
|
||||
defer pairingRetry.Unlock()
|
||||
next, ok := pairingRetry.next[host]
|
||||
return !ok || !time.Now().Before(next)
|
||||
}
|
||||
|
||||
// pairingRecovered clears host's record: its run has stayed up, or it ended
|
||||
// cleanly.
|
||||
func pairingRecovered(host string) {
|
||||
pairingRetry.Lock()
|
||||
defer pairingRetry.Unlock()
|
||||
if _, ok := pairingRetry.next[host]; !ok {
|
||||
return
|
||||
}
|
||||
delete(pairingRetry.next, host)
|
||||
delete(pairingRetry.tries, host)
|
||||
m := loadPairingFailures()
|
||||
if _, ok := m[host]; ok {
|
||||
delete(m, host)
|
||||
savePairingFailuresLocked(m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// anchoredWithPending is the S0 machine: anchored to a Host on this machine,
|
||||
// with a remote Host that redeemed its code and waits.
|
||||
func anchoredWithPending(t *testing.T) {
|
||||
t.Helper()
|
||||
localBox(t)
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
if err := writeAnchor("the owner", tLocal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pd, _ := pairingsDir()
|
||||
b, _ := json.Marshal(Config{HostName: tRemote, Transport: transportTCP, LaneAddr: "10.0.2.2:28811", WGPrivateKey: "k",
|
||||
State: pairingPending, ListenAddr: "127.0.0.1:7879", Origin: pairingOriginCarried})
|
||||
if err := os.WriteFile(filepath.Join(pd, tRemote+".json"), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// `pair` on an anchored machine says the code was used, that the Host waits
|
||||
// for the anchor to admit it, and that the anchor asks the person.
|
||||
//
|
||||
// MUTATION: restore the 0.62 text ("a new host waits until the anchor host
|
||||
// approves it"). RESULT: red.
|
||||
func TestPairOnAnAnchoredMachineSaysWhoAdmitsTheHost(t *testing.T) {
|
||||
anchoredWithPending(t)
|
||||
cfg, all := pairingNamed(t, tRemote)
|
||||
out := pairReportOf(cfg, all, false)
|
||||
for _, want := range []string{
|
||||
"Paired with " + tRemote + " — WAITING for " + tLocal + " to admit it.",
|
||||
"The code was used",
|
||||
tLocal + " asks you to approve " + tRemote,
|
||||
"served at once, with no restart",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pair does not say %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "not answering") || strings.Contains(out, "privatae-bastion run") {
|
||||
t.Errorf("pair names a dead end:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// `pair` never tells the person to start a bastion that is running: the
|
||||
// running one serves the new pairing by itself. With none running it names
|
||||
// this machine's own start command.
|
||||
//
|
||||
// MUTATION: restore "Start (or restart) the bastion:\n privatae-bastion run".
|
||||
// RESULT: red.
|
||||
func TestPairNeverSaysRunWhenABastionServesTheMachine(t *testing.T) {
|
||||
localBox(t)
|
||||
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
|
||||
cfg, all := pairingNamed(t, tLocal)
|
||||
prev := pairDaemonRunning
|
||||
t.Cleanup(func() { pairDaemonRunning = prev })
|
||||
pairDaemonRunning = func() bool { return true }
|
||||
out := pairReportOf(cfg, all, false)
|
||||
if strings.Contains(out, "privatae-bastion run") || !strings.Contains(out, "serves it within 15 seconds; nothing needs restarting") {
|
||||
t.Errorf("with a bastion running:\n%s", out)
|
||||
}
|
||||
pairDaemonRunning = func() bool { return false }
|
||||
out = pairReportOf(cfg, all, false)
|
||||
if !strings.Contains(out, "Start the bastion so it serves it:\n "+runCommandHint()) {
|
||||
t.Errorf("with none running:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// `status` and `status --json` say a pending Host waits for the anchor to
|
||||
// admit it, never that it is not answering, and the machine is RUNNING when
|
||||
// every Host it may serve is served.
|
||||
//
|
||||
// MUTATION: count pending pairings as served in readStatus. RESULT: red
|
||||
// (PARTLY RUNNING). Drop WaitingFor. RESULT: red.
|
||||
func TestStatusSaysAPendingHostWaitsForTheAnchor(t *testing.T) {
|
||||
anchoredWithPending(t)
|
||||
s, err := readStatus(readOpts{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pend *statusPairing
|
||||
for i := range s.Pairings {
|
||||
if s.Pairings[i].Host == tRemote {
|
||||
pend = &s.Pairings[i]
|
||||
}
|
||||
}
|
||||
if pend == nil || pend.WaitingFor != tLocal {
|
||||
t.Fatalf("status --json does not say who admits it: %+v", s.Pairings)
|
||||
}
|
||||
raw, _ := json.Marshal(s)
|
||||
if !strings.Contains(string(raw), `"waiting_for":"`+tLocal+`"`) {
|
||||
t.Errorf("the JSON lacks waiting_for: %s", raw)
|
||||
}
|
||||
// As the daemon reads it, with the anchor's own lane answering.
|
||||
s.Running = "yes"
|
||||
for i := range s.Pairings {
|
||||
if s.Pairings[i].Host == tLocal {
|
||||
s.Pairings[i].Answering = true
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
printStatus(&b, s)
|
||||
out := b.String()
|
||||
if !strings.HasPrefix(out, "RUNNING — serving 1 host(s)") {
|
||||
t.Errorf("headline:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Waiting for "+tLocal+" to admit it: "+tRemote) ||
|
||||
!strings.Contains(out, "(waiting for "+tLocal+" to admit it)") {
|
||||
t.Errorf("status does not say who admits it:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "not answering") {
|
||||
t.Errorf("a pending Host reads as not answering:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The console says the same, and the page shows it in place of "not answering".
|
||||
//
|
||||
// MUTATION: drop the WaitingFor branch in status.html. RESULT: red.
|
||||
func TestTheConsoleSaysAPendingHostWaitsForTheAnchor(t *testing.T) {
|
||||
anchoredWithPending(t)
|
||||
s := collectUIStatus()
|
||||
if strings.Contains(s.StateNote, "not answering") || !strings.Contains(s.StateNote, "wait(s) for "+tLocal+" to admit them") {
|
||||
t.Errorf("state %q: %q", s.State, s.StateNote)
|
||||
}
|
||||
found := false
|
||||
for _, p := range s.Pairings {
|
||||
found = found || (p.Host == tRemote && p.WaitingFor == tLocal)
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("pairings %+v", s.Pairings)
|
||||
}
|
||||
page, err := os.ReadFile(filepath.Join("uiassets", "status.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i, j := strings.Index(string(page), "{{if .WaitingFor}}<span class=\"tag warn\">not served until"), strings.Index(string(page), "not answering</span>")
|
||||
if i < 0 || j < 0 || i > j {
|
||||
t.Error("the page shows a pending Host as not answering")
|
||||
}
|
||||
if line := trayStateLine(s); !strings.Contains(line, "waiting for "+tLocal) {
|
||||
t.Errorf("tray: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// The anchor's heartbeat carries each pending pairing with what the anchor
|
||||
// Host needs for the approval prompt: who admits it, how it would run, where
|
||||
// its Host is, and how it came.
|
||||
//
|
||||
// MUTATION: drop the pending fields in pairingsReport. RESULT: red.
|
||||
func TestTheAnchorsBeatCarriesWhatItNeedsToAskThePerson(t *testing.T) {
|
||||
anchoredWithPending(t)
|
||||
var e *pairingEntry
|
||||
for _, p := range pairingsReport() {
|
||||
if p.HostName == tRemote {
|
||||
p := p
|
||||
e = &p
|
||||
}
|
||||
}
|
||||
if e == nil || e.State != pairingPending || e.WaitingFor != tLocal || e.Transport != "tcp" || e.SameMachine ||
|
||||
e.Origin != pairingOriginCarried || e.Handle != handleOf(tRemote) {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
for _, p := range pairingsReport() {
|
||||
if p.HostName == tLocal && (p.WaitingFor != "" || p.Origin != "") {
|
||||
t.Errorf("an active pairing carries the pending fields: %+v", p)
|
||||
}
|
||||
}
|
||||
// The unapproved list keeps its four fields.
|
||||
raw, _ := json.Marshal(unapprovedAttempts(time.Now().Add(time.Hour)))
|
||||
if strings.Contains(string(raw), "waiting_for") {
|
||||
t.Errorf("unapproved attempts grew fields: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The installer's last words come from what the service holds: what it serves
|
||||
// and what waits, before the "no Host is needed" line it said whatever the
|
||||
// service served.
|
||||
//
|
||||
// MUTATION: drop the status --json read. RESULT: red.
|
||||
func TestTheInstallerSaysWhatTheServiceServesAndWhatWaits(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.ps1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
read := strings.Index(s, "$st = (& $exe status --json 2>$null | Out-String) | ConvertFrom-Json")
|
||||
waits := strings.Index(s, "waits for $($p.waiting_for) to admit it")
|
||||
none := strings.Index(s, "no Host is needed.")
|
||||
if read < 0 || waits < 0 || none < 0 || !(read < waits && waits < none) {
|
||||
t.Error("the installer's end text does not come from what the service serves")
|
||||
}
|
||||
if !strings.Contains(s, "if ($served -or $waits) {") {
|
||||
t.Error("the 'no Host is needed' line is not kept for a service that serves none")
|
||||
}
|
||||
}
|
||||
+4
-10
@@ -106,8 +106,9 @@ func runCommandHintFor(goos string) string {
|
||||
return "systemctl start privatae-bastion"
|
||||
}
|
||||
|
||||
// restartHint is how to bring the daemon back on THIS machine after a change
|
||||
// that only takes at startup.
|
||||
// restartHintFor is how to bring the daemon back after a change that only
|
||||
// takes at startup, on goos (claim-anchor and pair no longer need it: a
|
||||
// running bastion follows the anchor and the pairing files by itself).
|
||||
//
|
||||
// It said `systemctl restart privatae-bastion` unconditionally — the third
|
||||
// Linux instruction printed at a Windows user inside one command's output,
|
||||
@@ -116,7 +117,7 @@ func runCommandHintFor(goos string) string {
|
||||
// service: deploy/install.ps1 installs the exe and registers a logon task. So
|
||||
// the honest instruction there is to start it again. A networked install on
|
||||
// macOS or Windows runs as a system service (sysservice.go), and for that
|
||||
// machine restartHint names the service manager's own restart instead.
|
||||
// machine serviceRestartHintFor names the service manager's own restart instead.
|
||||
//
|
||||
// TAKES THE PLATFORM AS AN ARGUMENT, and that is the point. The first version
|
||||
// branched on runtime.GOOS directly, so the only machine that could test the
|
||||
@@ -124,13 +125,6 @@ func runCommandHintFor(goos string) string {
|
||||
// passed clean on the Linux builder. That is the same hole that let 'darwin'
|
||||
// match 'win' in the dashboard: a platform branch nothing off-platform can
|
||||
// reach is a platform branch nothing checks.
|
||||
func restartHint() string {
|
||||
if systemService.installed && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
|
||||
return serviceRestartHintFor(runtime.GOOS)
|
||||
}
|
||||
return restartHintFor(runtime.GOOS)
|
||||
}
|
||||
|
||||
func restartHintFor(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "Start the bastion again so the change takes: privatae-bastion run\n" +
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The member repository publishes this tree to strangers byte for byte, and
|
||||
// its release step refuses outright a tree that carries any of the classes
|
||||
// below. Each is checked here as well, so a change that brings one in fails
|
||||
// before it merges, not after a release has been built and signed from it.
|
||||
//
|
||||
// A class is described, never named: a comment says what happened and why, not
|
||||
// on which of our machines, by whom, under which ticket, or on which day it was
|
||||
// decided. Test data is invented (example.com, .example, the 9000 range).
|
||||
//
|
||||
// Not checked here: names held outside this repository (they cannot be listed
|
||||
// in a published file; the release step and the personal-data check read them
|
||||
// from the machine they are kept on), and the two classes the release step
|
||||
// compares against the previous release instead of refusing outright: paths
|
||||
// under root's home directory, which the bastion's own state and its tests of
|
||||
// credential-store refusals legitimately use, and the bastion's own tunnel
|
||||
// addresses in the private range.
|
||||
//
|
||||
// Every pattern is written so that this file does not match it: a bracketed
|
||||
// letter keeps the text from being the word it finds.
|
||||
var publishedInternalClasses = []struct {
|
||||
name string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{"a machine of the platform's own fleet",
|
||||
regexp.MustCompile(`mgmt-[12]|platform-meta[l]|entity-poo[l]|edge-vi[n]|edge-hi[l]|dmz-[a-z]+-[0-9]`)},
|
||||
{"a credential store of the platform",
|
||||
regexp.MustCompile(`gitea-[s]kema-credentials|/roo[t]/\.config/git|INTERNAL_API_SECRE[T]`)},
|
||||
{"a ticket id", regexp.MustCompile(`T-[0-9]{4}\b|R-[0-9]{3}\b`)},
|
||||
{"the name of a person or a review agent who worked on the code",
|
||||
regexp.MustCompile(`\b(Fabl[e]|Maddi[e]|Opu[s])\b|[Cc]ase[y]`)},
|
||||
{"a dated decision", regexp.MustCompile(`(Decided|Reported|Measured|measured) 20[0-9]{2}-[0-9]{2}`)},
|
||||
{"a design document reference", regexp.MustCompile(`DESIGN-[a-z]|plan [§]`)},
|
||||
{"the old module path", regexp.MustCompile(`github\.com/privata[e]`)},
|
||||
{"the platform's install directory", regexp.MustCompile(`/opt/privata[e]`)},
|
||||
{"an address in the platform's fleet mesh", regexp.MustCompile(`fd0[0]:aa:`)},
|
||||
}
|
||||
|
||||
// publishedInternalHits returns "class: line N" for every line of src that a
|
||||
// class matches.
|
||||
func publishedInternalHits(src string) []string {
|
||||
var hits []string
|
||||
for i, line := range strings.Split(src, "\n") {
|
||||
for _, c := range publishedInternalClasses {
|
||||
if c.re.MatchString(line) {
|
||||
hits = append(hits, strconv.Itoa(i+1)+": "+c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
func TestPublishedSourceNamesNothingInternal(t *testing.T) {
|
||||
root, err := filepath.Abs("..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := publishedFiles(t, root)
|
||||
if len(files) < 50 {
|
||||
t.Fatalf("found only %d files under %s; the scan is not looking at the repository", len(files), root)
|
||||
}
|
||||
var hits []string
|
||||
for _, rel := range files {
|
||||
for _, h := range publishedInternalHits(rel) {
|
||||
hits = append(hits, rel+" (in the file name) "+h[strings.Index(h, " ")+1:])
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
continue // a tracked file deleted in the working tree
|
||||
}
|
||||
for _, h := range publishedInternalHits(string(data)) {
|
||||
hits = append(hits, rel+":"+h)
|
||||
}
|
||||
}
|
||||
// The lines themselves are not printed: a hit of the first two classes can
|
||||
// be the very value that must not be published, and a CI log is published too.
|
||||
if len(hits) > 0 {
|
||||
t.Errorf("%d published lines name something internal; describe it instead, with invented values:\n %s",
|
||||
len(hits), strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// The check itself: each class fires on a planted line and stays quiet on its
|
||||
// look-alikes. Planted lines carry a "|" that is removed before they are read.
|
||||
func TestPublishedInternalCheckCatchesEachClass(t *testing.T) {
|
||||
live := func(s string) string { return strings.ReplaceAll(s, "|", "") }
|
||||
for _, line := range []string{
|
||||
"seen on mgmt|-2 at noon", "ssh platform-met|al", "the entity-po|ol nodes", "via edge-v|in",
|
||||
"edge-h|il took over", "dmz-ro|uter-1 is up",
|
||||
"read /ro|ot/.config/g|it/creds", "gitea-sk|ema-credentials", "INTERNAL_API_SEC|RET=x",
|
||||
"fixed in T-|1234", "see R-|101.",
|
||||
"Fab|le found it", "per Mad|die", "Op|us said so", "Ca|sey asked", `owner "ca|sey"`,
|
||||
"Decid|ed 2026-10-01: keep it", "measu|red 2026-11-03 on a box",
|
||||
"DESIGN|-bastion.md", "plan| § 4",
|
||||
"github.com/priv|atae/bastion", "/opt/priv|atae/bin", "addr fd0|0:aa::1",
|
||||
} {
|
||||
if len(publishedInternalHits(live(line))) == 0 {
|
||||
t.Errorf("%q: not caught", live(line))
|
||||
}
|
||||
}
|
||||
for _, line := range []string{
|
||||
"mgmt-3 is not ours", "platform metal", "edge-v",
|
||||
"dmz-router", "~/.config/git", "T-123 and T-12345x", "R-1000",
|
||||
"Fables and Opuses", "Maddies", "a case, yes", "decided in 2026",
|
||||
"design doc", "github.com/example/thing", "/opt/example/privatae-bastion",
|
||||
"fd12:3456::a:1", "fdaa::1",
|
||||
} {
|
||||
if hits := publishedInternalHits(live(line)); len(hits) != 0 {
|
||||
t.Errorf("%q: false hit %v", line, hits)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,16 +20,10 @@ func laneSock(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go askSockServe(c)
|
||||
}
|
||||
}()
|
||||
// The globals are set before the server goroutine starts and restored only
|
||||
// after it and every connection it served have returned: a client's
|
||||
// connect is no happens-before edge the race detector sees, so a server
|
||||
// that read them unordered with these writes is a data race under -race.
|
||||
prevPath, prevLane, prevID := readSockPath, readsGoThroughLane, askPeerIdentityFn
|
||||
t.Cleanup(func() { readSockPath, readsGoThroughLane, askPeerIdentityFn = prevPath, prevLane, prevID })
|
||||
readSockPath = func() string { return sock }
|
||||
@@ -42,6 +37,20 @@ func laneSock(t *testing.T) {
|
||||
}
|
||||
return uid, who, err
|
||||
}
|
||||
var served sync.WaitGroup
|
||||
served.Add(1)
|
||||
t.Cleanup(func() { _ = ln.Close(); served.Wait() })
|
||||
go func() {
|
||||
defer served.Done()
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
served.Add(1)
|
||||
go func() { defer served.Done(); askSockServe(c) }()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
const laneTestUID = 1000
|
||||
|
||||
+247
-43
@@ -153,6 +153,10 @@ func cmdRun() error {
|
||||
go superviseLoop(ctx)
|
||||
// The machine owner's approved stops (stop_local.go), Host or no Host.
|
||||
go localStopLoop(ctx)
|
||||
// The door's own firewall rules (doorfw.go): one left by a `pair --open`
|
||||
// that was killed, or a pairing that went while nothing ran, is removed
|
||||
// here once nothing uses its port.
|
||||
go doorFirewallSweep(ctx)
|
||||
// The local UI (ui.go): ONE per machine, not one per pairing, because it
|
||||
// shows the whole box. Started last so it renders a daemon that is already
|
||||
// up, and non-fatal by construction — a machine that cannot bind the UI port
|
||||
@@ -168,13 +172,15 @@ func cmdRun() error {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\nshutting down")
|
||||
tellReplacedAnchorsBeforeStop()
|
||||
cancel() // closes every pairing's server + WG device via runPairing's exits
|
||||
return nil
|
||||
case <-shutdownCh:
|
||||
// "Stop the bastion" in the tray menu. Deliberately the SAME path as a
|
||||
// signal rather than an os.Exit: a machine that stops serving must tear
|
||||
// its tunnels down, not vanish and leave the host waiting for a beat.
|
||||
fmt.Println("\nstopping — asked from the tray")
|
||||
fmt.Println("\nstopping — " + shutdownWhy)
|
||||
tellReplacedAnchorsBeforeStop()
|
||||
cancel()
|
||||
return nil
|
||||
case err := <-errc:
|
||||
@@ -245,10 +251,13 @@ func startPairing(cfg *Config) bool {
|
||||
pctx, pcancel := context.WithCancel(supervisor.ctx)
|
||||
run := &pairingRun{cancel: pcancel, done: make(chan struct{}), cfg: cfg}
|
||||
supervisor.runs[cfg.HostName] = run
|
||||
errc := supervisor.errc
|
||||
go func() {
|
||||
defer close(run.done)
|
||||
started := time.Now()
|
||||
// A run that stays up has recovered from any failure before it.
|
||||
up := time.AfterFunc(retryResetAfter, func() { pairingRecovered(cfg.HostName) })
|
||||
err := runPairing(pctx, cfg)
|
||||
up.Stop()
|
||||
// Retired BEFORE it leaves the registry: leaving is what lets the
|
||||
// pairing start again, and this run's heartbeat may still be running.
|
||||
retireRun(cfg)
|
||||
@@ -257,12 +266,15 @@ func startPairing(cfg *Config) bool {
|
||||
delete(supervisor.runs, cfg.HostName)
|
||||
}
|
||||
supervisor.Unlock()
|
||||
// nil is a clean exit (ctx cancelled: shutdown or a revoke) and must not
|
||||
// take the process down. Anything else is fatal for the whole daemon, as
|
||||
// before: the supervisor restarts all lanes together, never half-homed.
|
||||
if err != nil && errc != nil {
|
||||
errc <- fmt.Errorf("[%s] %w", cfg.HostName, err)
|
||||
// nil is a clean exit (ctx cancelled: shutdown or a revoke). Anything
|
||||
// else stops THIS pairing only: it is reported, and superviseOnce
|
||||
// starts it again after a pause (pairing_retry.go). It used to stop the
|
||||
// whole daemon, and every other Host with it.
|
||||
if err != nil && pctx.Err() == nil {
|
||||
pairingFailed(cfg.HostName, err, time.Since(started))
|
||||
return
|
||||
}
|
||||
pairingRecovered(cfg.HostName)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
@@ -311,6 +323,9 @@ func superviseOnce() {
|
||||
c, ok := onDisk[h]
|
||||
switch {
|
||||
case !ok || !c.active():
|
||||
if ok {
|
||||
tellReplacedAnchor(h)
|
||||
}
|
||||
fmt.Printf("[%s] no longer active on disk — stopping its tunnel and lanes\n", h)
|
||||
stopPairing(h)
|
||||
case r.cfg != nil && c.WGPrivateKey != r.cfg.WGPrivateKey:
|
||||
@@ -324,12 +339,108 @@ func superviseOnce() {
|
||||
}
|
||||
}
|
||||
for _, c := range cfgs {
|
||||
if c.active() {
|
||||
if c.active() && pairingRetryDue(c.HostName) {
|
||||
startPairing(c) // no-op when already running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tellReplacedAnchor: the anchor a Host the rules put first replaced
|
||||
// (anchor_local.go) is told so on one last beat, its enforcing report saying
|
||||
// who is in charge now and that it waits to be admitted, before its lanes stop.
|
||||
func tellReplacedAnchor(h string) {
|
||||
if anchorReplacedNote(h) != nil && lastBeat(h) {
|
||||
fmt.Printf("[%s] no longer in charge of this machine — told it on a last heartbeat\n", h)
|
||||
}
|
||||
}
|
||||
|
||||
// tellReplacedAnchorsBeforeStop is tellReplacedAnchor for every running
|
||||
// pairing the files on disk no longer serve, when the daemon stops: an
|
||||
// installer that pairs a Host on this machine and restarts the service at
|
||||
// once stops the replaced anchor before superviseOnce would have seen it.
|
||||
func tellReplacedAnchorsBeforeStop() {
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
supervisor.Lock()
|
||||
var running []string
|
||||
for h := range supervisor.runs {
|
||||
running = append(running, h)
|
||||
}
|
||||
supervisor.Unlock()
|
||||
for _, h := range running {
|
||||
if c := pairingFor(cfgs, h); c != nil && c.HostName == h && !c.active() {
|
||||
tellReplacedAnchor(h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runBeats are the running pairings' beats, for one last beat before a
|
||||
// pairing stops (superviseOnce).
|
||||
var runBeats = struct {
|
||||
sync.Mutex
|
||||
m map[string]*runBeat
|
||||
}{m: map[string]*runBeat{}}
|
||||
|
||||
// runBeat is one run's beat, serialised. lastBeat beats from superviseOnce's
|
||||
// goroutine while the run's own loop (runBeatLoop) may be beating or failing
|
||||
// over, and neither is safe to run beside the other: maybeRotateEndpoint and
|
||||
// maybeFailover both write cfg.Endpoints and the WG device. Before lastBeat
|
||||
// every beat of a run came from its one loop, so nothing locked them.
|
||||
type runBeat struct {
|
||||
mu sync.Mutex
|
||||
beat func()
|
||||
}
|
||||
|
||||
// do runs f with no beat of this run beside it.
|
||||
func (r *runBeat) do(f func()) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
f()
|
||||
}
|
||||
|
||||
// run is one beat.
|
||||
func (r *runBeat) run() { r.do(r.beat) }
|
||||
|
||||
// registerRunBeat records host's beat while its run lasts. The run beats
|
||||
// through the returned runBeat from then on; the func takes it out.
|
||||
func registerRunBeat(host string, beat func()) (*runBeat, func()) {
|
||||
mine := &runBeat{beat: beat}
|
||||
runBeats.Lock()
|
||||
runBeats.m[host] = mine
|
||||
runBeats.Unlock()
|
||||
return mine, func() {
|
||||
runBeats.Lock()
|
||||
if runBeats.m[host] == mine { // a later run of host keeps its own
|
||||
delete(runBeats.m, host)
|
||||
}
|
||||
runBeats.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// lastBeatWait bounds the last beat: a Host that does not answer does not hold
|
||||
// the stop.
|
||||
var lastBeatWait = 10 * time.Second
|
||||
|
||||
// lastBeat beats host once more, now, and waits for it (bounded). False when
|
||||
// host has no running beat.
|
||||
func lastBeat(host string) bool {
|
||||
runBeats.Lock()
|
||||
b := runBeats.m[host]
|
||||
runBeats.Unlock()
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); b.run() }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(lastBeatWait):
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stopPairing cancels one running pairing and waits (bounded) for its tunnel,
|
||||
// listener and lanes to exit. Returns whether anything was running.
|
||||
func stopPairing(host string) bool {
|
||||
@@ -687,13 +798,15 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
||||
// box costs 2.9 s because it hashes every executable once, and no beat
|
||||
// may ever wait on that.
|
||||
go runDeltaRefresh(ctx)
|
||||
beat() // first beat: flip online + bind fp now, don't wait a full interval
|
||||
rb, unregisterBeat := registerRunBeat(cfg.HostName, beat)
|
||||
defer unregisterBeat()
|
||||
rb.run() // first beat: flip online + bind fp now, don't wait a full interval
|
||||
// Every 60 s, and at once after a pairing on this box changes state
|
||||
// (beatnow.go), so this Host learns of it without waiting for the tick.
|
||||
// Path failover BEFORE a ticked beat: if the current relay entry has gone
|
||||
// dead (no handshake for RejectAfterTime) the beat could not reach the
|
||||
// host anyway; move to the next candidate first, no platform round trip.
|
||||
runBeatLoop(ctx, 60*time.Second, func() { maybeFailover(cfg, dev, startedAt) }, beat)
|
||||
runBeatLoop(ctx, 60*time.Second, func() { rb.do(func() { maybeFailover(cfg, dev, startedAt) }) }, rb.run)
|
||||
}()
|
||||
|
||||
srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux}
|
||||
@@ -769,11 +882,17 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A Host that connects to this server: its door is open while it is
|
||||
// served (hostdoor.go), and closed after the last such pairing stops.
|
||||
if cfg.hostDials() {
|
||||
closeDoor, err := doorOpen(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeDoor()
|
||||
}
|
||||
lane := cfg.laneWhere()
|
||||
upstream := &http.Client{Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: laneTLS.Clone(),
|
||||
}}
|
||||
upstream := &http.Client{Transport: laneHTTPTransport(dialHost, laneTLS)}
|
||||
|
||||
// Coder runs: the box-wide loop, then a unix pairing's run sockets, which
|
||||
// are registered before the harness workers come up so a run-up that
|
||||
@@ -908,10 +1027,12 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
|
||||
go runAlertsRefresh(ctx)
|
||||
go runOtherBastionRefresh(ctx)
|
||||
go runDeltaRefresh(ctx)
|
||||
beat()
|
||||
rb, unregisterBeat := registerRunBeat(cfg.HostName, beat)
|
||||
defer unregisterBeat()
|
||||
rb.run()
|
||||
// Every 60 s, and at once after a pairing on this box changes state
|
||||
// (beatnow.go).
|
||||
runBeatLoop(ctx, 60*time.Second, nil, beat)
|
||||
runBeatLoop(ctx, 60*time.Second, nil, rb.run)
|
||||
}()
|
||||
|
||||
srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux}
|
||||
@@ -1105,6 +1226,12 @@ type statusRead struct {
|
||||
Pairings []statusPairing `json:"pairings"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Anchor *statusAnchor `json:"anchor,omitempty"`
|
||||
// LLM: the OpenAI-compatible model server the llm lane serves
|
||||
// (llmlocal.go): set, found on this machine, or none.
|
||||
LLM *statusLLM `json:"llm,omitempty"`
|
||||
// Firewall: the door's way in through this machine's firewall
|
||||
// (doorfw.go): the bastion's own rules, and what it could not do.
|
||||
Firewall *statusFirewall `json:"firewall,omitempty"`
|
||||
}
|
||||
|
||||
type statusPairing struct {
|
||||
@@ -1121,6 +1248,14 @@ type statusPairing struct {
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Local string `json:"local"`
|
||||
Answering bool `json:"answering"`
|
||||
// WaitingFor: a pending pairing waits for this Host, the one in charge of
|
||||
// the machine, to admit it (anchor_local.go admitter). Nothing runs for
|
||||
// it until then, so it is never "not answering".
|
||||
WaitingFor string `json:"waiting_for,omitempty"`
|
||||
// Failing: the run last stopped on this error and is tried again by
|
||||
// itself (pairing_retry.go); the other pairings are served meanwhile.
|
||||
// Cleared once a run has stayed up for retryResetAfter.
|
||||
Failing *pairingFailure `json:"failing,omitempty"`
|
||||
}
|
||||
|
||||
type statusAnchor struct {
|
||||
@@ -1134,27 +1269,45 @@ func readStatus(readOpts) (statusRead, error) {
|
||||
if err != nil {
|
||||
return statusRead{}, fmt.Errorf("this machine's pairings cannot be read: %w", err)
|
||||
}
|
||||
s := statusRead{Version: clientVersion, Running: "no", Pairings: []statusPairing{}}
|
||||
s := statusRead{Version: clientVersion, Running: "no", Pairings: []statusPairing{}, Firewall: readDoorFirewallStatus()}
|
||||
if len(cfgs) == 0 {
|
||||
// With no Host the only thing listening is the console, so that is
|
||||
// what says whether the daemon is up.
|
||||
if uiDaemonListening() {
|
||||
s.Running = "yes"
|
||||
}
|
||||
s.LLM = llmStatus()
|
||||
return s, nil
|
||||
}
|
||||
running := 0
|
||||
running, served := 0, 0
|
||||
who := admitter()
|
||||
failures := loadPairingFailures()
|
||||
for _, cfg := range cfgs {
|
||||
p := statusPairing{Host: cfg.HostName, State: cfg.stateName(), Local: cfg.ListenAddr,
|
||||
Answering: bastionListening(cfg.ListenAddr)}
|
||||
if p.Answering {
|
||||
running++
|
||||
if cfg.invalid == "" && cfg.State == pairingPending && who != "" {
|
||||
p.WaitingFor = who
|
||||
}
|
||||
// Until a run has stayed up for retryResetAfter, its last failure is
|
||||
// shown, whether or not the port answers now.
|
||||
if f, ok := failures[cfg.HostName]; ok && cfg.active() {
|
||||
p.Failing = &f
|
||||
}
|
||||
// Only a pairing that is served counts: one waiting to be admitted
|
||||
// has nothing to answer with.
|
||||
if cfg.active() {
|
||||
served++
|
||||
if p.Answering {
|
||||
running++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case cfg.invalid != "":
|
||||
p.Problem = cfg.invalid
|
||||
case cfg.unixTransport():
|
||||
p.Transport, p.Lanes, p.Runs = "local", cfg.LaneSocket, cfg.RunsDir
|
||||
case cfg.hostDials():
|
||||
p.Transport, p.Lanes = "tcp", cfg.laneWhere()
|
||||
case cfg.tcpTransport():
|
||||
p.Transport, p.Lanes = "tcp", cfg.LaneAddr
|
||||
default:
|
||||
@@ -1163,12 +1316,18 @@ func readStatus(readOpts) (statusRead, error) {
|
||||
s.Pairings = append(s.Pairings, p)
|
||||
}
|
||||
switch {
|
||||
case running == len(cfgs):
|
||||
case served == 0:
|
||||
// Nothing to serve yet: the console says whether the daemon is up.
|
||||
if uiDaemonListening() {
|
||||
s.Running = "yes"
|
||||
}
|
||||
case running == served:
|
||||
s.Running = "yes"
|
||||
case running > 0:
|
||||
s.Running = "partly"
|
||||
}
|
||||
s.Mode = boxMode()
|
||||
s.LLM = llmStatus()
|
||||
if a := currentAnchor(); a != nil {
|
||||
s.Anchor = &statusAnchor{Host: a.AnchorHost, Owner: a.Owner, Since: a.ClaimedAt}
|
||||
}
|
||||
@@ -1214,6 +1373,10 @@ func printStatus(w io.Writer, s statusRead) {
|
||||
} else {
|
||||
fmt.Fprintf(w, "NOT RUNNING, and no Host is connected. Start it with `%s`.\n", runCommandHint())
|
||||
}
|
||||
if s.LLM != nil {
|
||||
fmt.Fprintf(w, "model server: %s\n", s.LLM.words())
|
||||
}
|
||||
printDoorFirewallStatus(w, s.Firewall)
|
||||
return
|
||||
}
|
||||
// IS IT RUNNING, AND WHERE — the first thing status should answer and the
|
||||
@@ -1225,22 +1388,41 @@ func printStatus(w io.Writer, s statusRead) {
|
||||
// a self-update the bastion has NO console at all — it is serving, and there
|
||||
// is nothing on screen that says so. status printed pairings and mode and
|
||||
// never once said whether a daemon was alive.
|
||||
served := 0
|
||||
served, servable := 0, 0
|
||||
waiting := map[string][]string{}
|
||||
var admitters []string
|
||||
for _, p := range s.Pairings {
|
||||
if p.Answering {
|
||||
served++
|
||||
if p.WaitingFor != "" {
|
||||
if len(waiting[p.WaitingFor]) == 0 {
|
||||
admitters = append(admitters, p.WaitingFor)
|
||||
}
|
||||
waiting[p.WaitingFor] = append(waiting[p.WaitingFor], p.Host)
|
||||
continue
|
||||
}
|
||||
if p.State == pairingActive {
|
||||
servable++
|
||||
if p.Answering {
|
||||
served++
|
||||
}
|
||||
}
|
||||
}
|
||||
switch s.Running {
|
||||
case "yes":
|
||||
fmt.Fprintf(w, "RUNNING — serving %d host(s) on this machine.\n", len(s.Pairings))
|
||||
case "partly":
|
||||
fmt.Fprintf(w, "PARTLY RUNNING — %d of %d hosts are being served; the rest are not answering.\n",
|
||||
served, len(s.Pairings))
|
||||
default:
|
||||
switch {
|
||||
case s.Running == "no":
|
||||
fmt.Fprintf(w, "NOT RUNNING — nothing is serving these pairings. Start it with `%s`.\n", runCommandHint())
|
||||
case servable == 0:
|
||||
fmt.Fprintln(w, "RUNNING — no Host is served yet.")
|
||||
case s.Running == "partly":
|
||||
fmt.Fprintf(w, "PARTLY RUNNING — %d of %d hosts are being served; the rest are not answering.\n",
|
||||
served, servable)
|
||||
default:
|
||||
fmt.Fprintf(w, "RUNNING — serving %d host(s) on this machine.\n", servable)
|
||||
}
|
||||
if served > 0 && runtime.GOOS == "windows" {
|
||||
for _, who := range admitters {
|
||||
hosts := waiting[who]
|
||||
fmt.Fprintf(w, " Waiting for %s to admit it: %s. %s asks you to approve it; it is served from then on.\n",
|
||||
who, strings.Join(hosts, ", "), who)
|
||||
}
|
||||
if served > 0 && runtime.GOOS == "windows" && !systemService.installed {
|
||||
fmt.Fprintln(w, " (No window? After a self-update it restarts itself detached, so it")
|
||||
fmt.Fprintln(w, " has no console. Task Manager → privatae-bastion.exe is where it lives.)")
|
||||
}
|
||||
@@ -1251,7 +1433,10 @@ func printStatus(w io.Writer, s statusRead) {
|
||||
continue
|
||||
}
|
||||
live := "not answering"
|
||||
if p.Answering {
|
||||
switch {
|
||||
case p.WaitingFor != "":
|
||||
live = "waiting for " + p.WaitingFor + " to admit it"
|
||||
case p.Answering:
|
||||
live = "answering"
|
||||
}
|
||||
switch p.Transport {
|
||||
@@ -1265,11 +1450,19 @@ func printStatus(w io.Writer, s statusRead) {
|
||||
fmt.Fprintf(w, "paired with: %s [%s]\n tunnel: %s -> %s via %s\n local: http://%s (%s)\n",
|
||||
p.Host, p.State, p.BastionIP, p.HostIP, p.Endpoint, p.Local, live)
|
||||
}
|
||||
if f := p.Failing; f != nil {
|
||||
fmt.Fprintf(w, " last stopped: %s (at %s, try %d); tried again by itself from %s, the other Hosts served meanwhile\n",
|
||||
strings.TrimRight(f.Error, "."), uiWhen(f.At), f.Tries, uiWhen(f.NextTry))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "mode: %s\n", s.Mode)
|
||||
if a := s.Anchor; a != nil {
|
||||
fmt.Fprintf(w, "anchor: %s (owner %q, since %s)\n", a.Host, a.Owner, a.Since)
|
||||
}
|
||||
if s.LLM != nil {
|
||||
fmt.Fprintf(w, "model server: %s\n", s.LLM.words())
|
||||
}
|
||||
printDoorFirewallStatus(w, s.Firewall)
|
||||
}
|
||||
|
||||
// ollamaModels reports whether a local ollama with the embeddings API answers
|
||||
@@ -1340,12 +1533,18 @@ func ollamaModelsAt(addrs []string) (bool, []string) {
|
||||
const llmForwardPort = 18080
|
||||
|
||||
// openAICompatModels detects a non-ollama LLM server (llama.cpp llama-server,
|
||||
// vLLM, anything OpenAI-compatible) via GET /v1/models. Only PRIVATAE_LLM_ADDR is
|
||||
// probed — unlike ollama there is no conventional default port worth guessing,
|
||||
// and the operator setting the env IS the opt-in. Same 200-is-alive rule as
|
||||
// /api/tags: a live server with an unparseable list still counts.
|
||||
// vLLM, anything OpenAI-compatible) via GET /v1/models at llmTarget:
|
||||
// PRIVATAE_LLM_ADDR, or the server found on this machine's loopback
|
||||
// (llmlocal.go). Same 200-is-alive rule as /api/tags: a live server with an
|
||||
// unparseable list still counts. A found server that no longer answers is
|
||||
// asked for again (llmTargetFailed).
|
||||
func openAICompatModels() (bool, []string) {
|
||||
return openAICompatModelsAt(llmTarget())
|
||||
t := llmTarget()
|
||||
ok, models := openAICompatModelsAt(t)
|
||||
if !ok {
|
||||
llmTargetFailed(t)
|
||||
}
|
||||
return ok, models
|
||||
}
|
||||
|
||||
// openAICompatModelsAt is openAICompatModels' probe of one address.
|
||||
@@ -1393,14 +1592,14 @@ func localOllamaAddr() string {
|
||||
|
||||
// serveLLMForward is serveOllamaForward's twin for the OpenAI-compatible
|
||||
// server: host dials <bastion tunnel IP>:18080, we proxy raw TCP to
|
||||
// PRIVATAE_LLM_ADDR. Only runs when the env is set (the operator's opt-in).
|
||||
// llmTarget (PRIVATAE_LLM_ADDR, or the server found on this machine, llmlocal.go).
|
||||
// The "llm-openai" heartbeat capability is the host's signal that this
|
||||
// listener exists and speaks the /v1 dialect.
|
||||
func serveLLMForward(ctx context.Context, self string, tnet *netstack.Net, gwIP netip.Addr) {
|
||||
target := llmTarget()
|
||||
if target == "" {
|
||||
return
|
||||
}
|
||||
// Listening whether or not a server is there now: the one found on this
|
||||
// machine (llmlocal.go) may start after the bastion, and the beat
|
||||
// advertises the lane only while llmTarget names one. Each connection
|
||||
// goes to the server llmTarget names at that moment.
|
||||
ln, err := tnet.ListenTCP(&net.TCPAddr{IP: gwIP.AsSlice(), Port: llmForwardPort})
|
||||
if err != nil {
|
||||
return
|
||||
@@ -1415,6 +1614,11 @@ func serveLLMForward(ctx context.Context, self string, tnet *netstack.Net, gwIP
|
||||
if refused(in) {
|
||||
continue
|
||||
}
|
||||
target := llmTarget()
|
||||
if target == "" {
|
||||
_ = in.Close()
|
||||
continue
|
||||
}
|
||||
// A pairing whose grant NAMES models gets an HTTP-aware proxy
|
||||
// that checks the model on each request; one with a free hand gets the
|
||||
// raw splice this lane has always been (inferencefilter.go).
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every script in scripts/ that starts a bastion in namespaces of its own
|
||||
// hides ALL of /run and /tmp, never a chosen subdirectory of them, and refuses
|
||||
// to go on while it can still see the machine's own bastion. A run that put a
|
||||
// tmpfs over /run/privatae but not /run/privatae-bastion replaced a live
|
||||
// bastion's ask.sock and context.sock on the machine running the tests.
|
||||
//
|
||||
// MUTATION: the e2e script's /run mount back to /run/privatae alone. RESULT: red.
|
||||
// MUTATION: drop its guard for /run/privatae-bastion. RESULT: red.
|
||||
// MUTATION: move the guard after the first start of the bastion. RESULT: red.
|
||||
// MUTATION: drop the PID 1 check before the first mount. RESULT: red.
|
||||
func TestNamespaceScriptsHideAllOfRunAndTmpAndRefuseToSeeTheLiveBastion(t *testing.T) {
|
||||
paths, err := filepath.Glob(filepath.Join("..", "scripts", "*.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, p := range paths {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
if !strings.Contains(s, "unshare") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
name := filepath.Base(p)
|
||||
lines := strings.Split(s, "\n")
|
||||
at := func(re string) int {
|
||||
r := regexp.MustCompile(re)
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "#") {
|
||||
continue
|
||||
}
|
||||
if r.MatchString(l) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
runAt := at(`mount -t tmpfs tmpfs /run\s*$`)
|
||||
tmpAt := at(`mount -t tmpfs tmpfs /tmp\s*$`)
|
||||
if runAt < 0 || tmpAt < 0 {
|
||||
t.Errorf("%s: no tmpfs over all of /run (%d) and /tmp (%d)", name, runAt, tmpAt)
|
||||
}
|
||||
if i := at(`mount -t tmpfs tmpfs /(run|tmp)/`); i >= 0 && (runAt < 0 || i < runAt) {
|
||||
t.Errorf("%s:%d hides a subdirectory of /run or /tmp instead of all of it: %s", name, i+1, lines[i])
|
||||
}
|
||||
guard := at(`/run/privatae-bastion`)
|
||||
if guard < 0 || guard < runAt || guard < tmpAt {
|
||||
t.Errorf("%s: no guard, after hiding /run and /tmp, that refuses to run while /run/privatae-bastion is visible", name)
|
||||
}
|
||||
if i := at(`"\$B"|privatae-bastion" (run|pair)|\$BAST`); i >= 0 && guard > i {
|
||||
t.Errorf("%s:%d starts a bastion before the guard", name, i+1)
|
||||
}
|
||||
if at(`unshare .*--pid`) < 0 || at(`unshare .*--mount`) < 0 || at(`unshare .*--net`) < 0 {
|
||||
t.Errorf("%s: a bastion runs in private PID, mount and network namespaces", name)
|
||||
}
|
||||
// The re-exec into the namespaces is chosen by an environment variable
|
||||
// (E2E_INNER): one set by the caller skips the unshare, and every mount
|
||||
// below then lands on this machine's own /run. So before any mount the
|
||||
// script proves it is PID 1 of a namespace of its own.
|
||||
pid1 := at(`\[ "?\$\$"? (=|-eq) "?1"? \]`)
|
||||
if m := at(`^\s*(mount|mkdir -p [^;&]*&& mount) `); pid1 < 0 || (m >= 0 && pid1 > m) {
|
||||
t.Errorf("%s: no check, before its first mount (line %d), that it runs as PID 1 of its own namespace (line %d)", name, m+1, pid1+1)
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("no namespace script found: the glob is wrong")
|
||||
}
|
||||
}
|
||||
@@ -607,11 +607,7 @@ func serveTargetNoticeClear(self string, s serveService) {
|
||||
// pairing's workers exit.
|
||||
func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *tls.Config,
|
||||
dialHost func(ctx context.Context) (net.Conn, error), bearer string, n int) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
var spawn func()
|
||||
spawn = func() {
|
||||
go func() {
|
||||
|
||||
+462
-20
@@ -15,7 +15,15 @@ package main
|
||||
// into the service's before the service starts, when the service has no
|
||||
// pairing of its own yet:
|
||||
//
|
||||
// - only then: a service that already serves a Host is never merged into;
|
||||
// - a service that already serves a Host takes the account's pairings too,
|
||||
// with no question (a move to the service never leaves a pairing
|
||||
// behind). Only the pairings for Hosts the service does not serve
|
||||
// come in, each with its own folders and a loopback port of its own
|
||||
// (carryAddedHosts); nothing of the account's that the service keeps
|
||||
// machine-wide (keys, owner, code, anchor, mode, audit) ever becomes the
|
||||
// service's. On an anchored machine each added pairing waits, pending,
|
||||
// for the anchor Host to admit it, as any new pairing there does; on an
|
||||
// unanchored one it is served as it was;
|
||||
// - not while a bastion still runs from that folder (its run lock is held,
|
||||
// waited for a moment), and the lock is held through the copy, the check
|
||||
// and the emptying, so none starts in the middle;
|
||||
@@ -76,6 +84,16 @@ type carryResult struct {
|
||||
// differed; that version is beside each, named with carryAsideSuffix.
|
||||
Aside []string
|
||||
Note string // why nothing was carried, when nothing was
|
||||
// Beside: the Hosts the service already served, when Hosts were added
|
||||
// beside them (carryAddedHosts); Hosts are then the ones added.
|
||||
Beside []string
|
||||
// Pending: the added pairings wait for the machine's anchor to admit them;
|
||||
// Anchor names it.
|
||||
Pending bool
|
||||
Anchor string
|
||||
// Stayed: the account's pairings for Hosts the service already served,
|
||||
// left in the account's folder.
|
||||
Stayed []string
|
||||
// Left: the pairings are in the service, but the account's folder could
|
||||
// not be removed, and still holds copies of their keys.
|
||||
Left string
|
||||
@@ -114,6 +132,29 @@ func carryLock(f *os.File) error {
|
||||
}
|
||||
}
|
||||
|
||||
// carryTakeLock takes the account folder's run lock for a carry and returns
|
||||
// what lets it go. It is held until only the lock itself is left to remove, so
|
||||
// no bastion starts from the folder while it is copied, checked and emptied;
|
||||
// letting it go twice is harmless.
|
||||
func carryTakeLock(from string) (func(), error) {
|
||||
lock, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot open the run lock under %s: %w", from, err)
|
||||
}
|
||||
if err := carryLock(lock); err != nil {
|
||||
_ = lock.Close()
|
||||
return nil, fmt.Errorf("a bastion is still running from %s (it holds that folder's run lock), so its pairings "+
|
||||
"were not moved into the service. Stop it, then run `privatae-bastion service install` again", from)
|
||||
}
|
||||
locked := true
|
||||
return func() {
|
||||
if locked {
|
||||
_ = lock.Close()
|
||||
locked = false
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// carryStateInto moves the bastion state at from into to (see the header).
|
||||
func carryStateInto(from, to string) (carryResult, error) {
|
||||
var res carryResult
|
||||
@@ -136,27 +177,11 @@ func carryStateInto(from, to string) (carryResult, error) {
|
||||
return res, fmt.Errorf("cannot read the service's pairings under %s: %w", to, err)
|
||||
}
|
||||
if len(have) > 0 {
|
||||
res.Note = fmt.Sprintf("the service already serves %s, so the pairings under %s (%s) were left where they are",
|
||||
strings.Join(have, ", "), from, strings.Join(hosts, ", "))
|
||||
return res, nil
|
||||
return carryAddedHosts(from, to, hosts, have)
|
||||
}
|
||||
lock, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
unlock, err := carryTakeLock(from)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("cannot open the run lock under %s: %w", from, err)
|
||||
}
|
||||
if err := carryLock(lock); err != nil {
|
||||
_ = lock.Close()
|
||||
return res, fmt.Errorf("a bastion is still running from %s (it holds that folder's run lock), so its pairings "+
|
||||
"were not moved into the service. Stop it, then run `privatae-bastion service install` again", from)
|
||||
}
|
||||
// Held until only the lock itself is left to remove, so no bastion starts
|
||||
// from the folder while it is copied, checked and emptied.
|
||||
locked := true
|
||||
unlock := func() {
|
||||
if locked {
|
||||
_ = lock.Close()
|
||||
locked = false
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
defer unlock()
|
||||
// A copy that fails takes back every file it wrote: a service left with
|
||||
@@ -373,6 +398,25 @@ func carryReport(r carryResult, from string) string {
|
||||
}
|
||||
s := " Moved this machine's pairings into the service from " + from + ": " + strings.Join(r.Hosts, ", ") +
|
||||
". They go on serving as they were; nothing needs pairing again."
|
||||
if len(r.Beside) > 0 {
|
||||
s = " Added " + strings.Join(r.Hosts, ", ") + " to the service, beside " + strings.Join(r.Beside, ", ") +
|
||||
", from " + from + "; nothing needs pairing again."
|
||||
if !r.Pending {
|
||||
s += " Each goes on serving as it was."
|
||||
}
|
||||
if r.Pending {
|
||||
if r.Anchor != "" {
|
||||
s += " Each waits until " + r.Anchor + ", the Host in charge of this machine, admits it: " + r.Anchor +
|
||||
" asks you to approve it, and it is served from then on."
|
||||
} else {
|
||||
s += " Each waits until the Host in charge of this machine admits it."
|
||||
}
|
||||
}
|
||||
if len(r.Stayed) > 0 {
|
||||
s += " The service already served " + strings.Join(r.Stayed, ", ") + ", so your account's pairing for it " +
|
||||
"was left in " + from + "."
|
||||
}
|
||||
}
|
||||
if r.Kept > 0 {
|
||||
s += fmt.Sprintf(" %d file(s) the service already had were kept as the service's.", r.Kept)
|
||||
}
|
||||
@@ -385,3 +429,401 @@ func carryReport(r carryResult, from string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// carryHostRoots are the folders under from that belong to host's pairing
|
||||
// alone (config.go contextRoot, dcnDir; backupfetch.go backupRootPath;
|
||||
// mirror.go mirrorDir), and any other folder or file its pairing names under
|
||||
// from, relative to from. A pairing that keeps its state in a folder every
|
||||
// pairing shares (the layout before 0.10.0, migrateLegacyConfig) is refused:
|
||||
// that folder is not its alone to move.
|
||||
func carryHostRoots(raw []byte, from, host string) ([]string, error) {
|
||||
roots := map[string]bool{}
|
||||
for _, top := range []string{"context", "dcn", "backups", "mirror"} {
|
||||
roots[filepath.Join(top, host)] = true
|
||||
}
|
||||
var v any
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, fmt.Errorf("the pairing for %s does not read: %w", host, err)
|
||||
}
|
||||
var walkErr error
|
||||
var walk func(any)
|
||||
walk = func(x any) {
|
||||
switch t := x.(type) {
|
||||
case string:
|
||||
if _, ok := rebasePath(t, from, from); !ok {
|
||||
return
|
||||
}
|
||||
rel, err := filepath.Rel(from, t)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
first := strings.SplitN(filepath.ToSlash(rel), "/", 2)[0]
|
||||
switch {
|
||||
case first == "pairings":
|
||||
return
|
||||
case rel == "." || !strings.Contains(filepath.ToSlash(rel), "/"):
|
||||
if walkErr == nil {
|
||||
walkErr = fmt.Errorf("the pairing for %s keeps its state in %s, which every pairing under %s "+
|
||||
"shares, so it was not added", host, t, from)
|
||||
}
|
||||
return
|
||||
}
|
||||
roots[rel] = true
|
||||
case []any:
|
||||
for _, e := range t {
|
||||
walk(e)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, e := range t {
|
||||
walk(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(v)
|
||||
if walkErr != nil {
|
||||
return nil, walkErr
|
||||
}
|
||||
var out []string
|
||||
for r := range roots {
|
||||
out = append(out, r)
|
||||
}
|
||||
sort.Strings(out)
|
||||
// A root inside another is the other's already.
|
||||
var kept []string
|
||||
for _, r := range out {
|
||||
inside := false
|
||||
for _, k := range kept {
|
||||
if strings.HasPrefix(filepath.ToSlash(r)+"/", filepath.ToSlash(k)+"/") {
|
||||
inside = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inside {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// carryUsedAddrs are the loopback addresses the service's own pairings hold:
|
||||
// their control ports and git proxies (config.go nextListenAddr,
|
||||
// nextGitProxyAddr). An added pairing that held one of them in the account
|
||||
// would fail to bind beside the pairing that has it here.
|
||||
func carryUsedAddrs(to string) (map[string]bool, error) {
|
||||
used := map[string]bool{}
|
||||
hosts, err := pairingHosts(to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, h := range hosts {
|
||||
raw, err := os.ReadFile(filepath.Join(to, "pairings", h+".json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, fmt.Errorf("the service's pairing for %s does not read: %w", h, err)
|
||||
}
|
||||
used[c.ListenAddr], used[c.GitProxyAddr] = true, true
|
||||
}
|
||||
delete(used, "")
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// carryFreeAddr is the first loopback port from start up that used does not
|
||||
// hold, marked used.
|
||||
func carryFreeAddr(start int, used map[string]bool) string {
|
||||
for port := start; ; port++ {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
if !used[addr] {
|
||||
used[addr] = true
|
||||
return addr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// carriedPairing is an account's pairing as the service keeps it: its paths
|
||||
// moved under to, its loopback ports its own among the service's, and on an
|
||||
// anchored machine pending, as `pair` writes any new pairing there
|
||||
// (pairPrecheckState). Every other field, its keys and bearer included, is
|
||||
// as it was.
|
||||
func carriedPairing(raw []byte, from, to string, used map[string]bool, anchored bool) ([]byte, error) {
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(bytes.NewReader(rebaseJSONPaths(raw, from, to)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
la, _ := m["listen_addr"].(string)
|
||||
if la == "" || used[la] {
|
||||
la = carryFreeAddr(7878, used)
|
||||
}
|
||||
used[la] = true
|
||||
m["listen_addr"] = la
|
||||
if ga, _ := m["git_proxy_addr"].(string); ga != "" {
|
||||
if used[ga] {
|
||||
ga = carryFreeAddr(7978, used)
|
||||
}
|
||||
used[ga] = true
|
||||
m["git_proxy_addr"] = ga
|
||||
}
|
||||
// On a machine an organisation's policy governs, a Host it does not name
|
||||
// waits for the one it does, anchored yet or not, as `pair` writes it
|
||||
// (pairPrecheckState).
|
||||
host, _ := m["host_name"].(string)
|
||||
if p := currentAnchorPolicy(); anchored || (p != nil && !policyNames(p, host)) {
|
||||
m["state"] = pairingPending
|
||||
}
|
||||
m["origin"] = pairingOriginCarried
|
||||
return json.MarshalIndent(m, "", " ")
|
||||
}
|
||||
|
||||
// carryAddedHosts adds the account's pairings for Hosts the service does not
|
||||
// serve to a service that serves others (see the
|
||||
// header). The account's pairings for Hosts the service already serves stay
|
||||
// where they are. When none stays, the rest of the account's folder (its
|
||||
// keys, owner, code, anchor, mode, audit) is kept beside the service's own
|
||||
// files with carryAsideSuffix, never in their place, and the folder is
|
||||
// removed, as a whole carry removes it.
|
||||
func carryAddedHosts(from, to string, hosts, have []string) (carryResult, error) {
|
||||
var res carryResult
|
||||
serving := map[string]bool{}
|
||||
for _, h := range have {
|
||||
serving[h] = true
|
||||
}
|
||||
var add, stay []string
|
||||
for _, h := range hosts {
|
||||
if serving[h] {
|
||||
stay = append(stay, h)
|
||||
} else {
|
||||
add = append(add, h)
|
||||
}
|
||||
}
|
||||
if len(add) == 0 {
|
||||
res.Note = fmt.Sprintf("the service already serves every Host the pairings under %s are for (%s), so nothing "+
|
||||
"was added", from, strings.Join(hosts, ", "))
|
||||
return res, nil
|
||||
}
|
||||
unlock, err := carryTakeLock(from)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
defer unlock()
|
||||
_, aerr := os.Lstat(filepath.Join(to, "anchor.json"))
|
||||
anchored := aerr == nil
|
||||
anchorName := ""
|
||||
if raw, err := os.ReadFile(filepath.Join(to, "anchor.json")); err == nil {
|
||||
var a struct {
|
||||
AnchorHost string `json:"anchor_host"`
|
||||
}
|
||||
if json.Unmarshal(raw, &a) == nil && validHostName(a.AnchorHost) {
|
||||
anchorName = a.AnchorHost
|
||||
}
|
||||
}
|
||||
used, err := carryUsedAddrs(to)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("cannot read the service's pairings under %s: %w", to, err)
|
||||
}
|
||||
|
||||
type item struct {
|
||||
rel string
|
||||
data []byte // set: written as it is; nil: read from from and rebased
|
||||
aside bool // kept beside the service's file of that name, never in its place
|
||||
}
|
||||
var items, last []item
|
||||
carried := map[string]bool{} // rel paths (and roots) this carry moves
|
||||
for _, h := range add {
|
||||
praw, err := os.ReadFile(filepath.Join(from, "pairings", h+".json"))
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("cannot read the pairing for %s under %s: %w", h, from, err)
|
||||
}
|
||||
roots, err := carryHostRoots(praw, from, h)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, r := range roots {
|
||||
carried[r] = true
|
||||
err := filepath.WalkDir(filepath.Join(from, r), func(p string, d fs.DirEntry, err error) error {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(from, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = append(items, item{rel: rel})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("cannot read %s: %w", filepath.Join(from, r), err)
|
||||
}
|
||||
}
|
||||
data, err := carriedPairing(praw, from, to, used, anchored)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("the pairing for %s does not read: %w", h, err)
|
||||
}
|
||||
prel := filepath.Join("pairings", h+".json")
|
||||
grel := filepath.Join("pairings", h+pairingGrantSuffix)
|
||||
carried[prel] = true
|
||||
last = append(last, item{rel: prel, data: data})
|
||||
// A grant is the anchor's word for this machine. On an anchored
|
||||
// machine the account's grant was another anchor's, and the pairing
|
||||
// waits for this one's instead; it is kept beside, like any file of
|
||||
// the account's the service does not take.
|
||||
if _, err := os.Lstat(filepath.Join(from, grel)); err == nil && !anchored {
|
||||
carried[grel] = true
|
||||
last = append(last, item{rel: grel})
|
||||
}
|
||||
}
|
||||
if len(stay) == 0 {
|
||||
// Nothing stays: the rest of the folder goes beside the service's.
|
||||
err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(from, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if carried[rel] {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() || (rel == d.Name() && carrySkip[d.Name()]) {
|
||||
return nil
|
||||
}
|
||||
items = append(items, item{rel: rel, aside: true})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("cannot read %s: %w", from, err)
|
||||
}
|
||||
}
|
||||
|
||||
written := map[string][]byte{}
|
||||
undo := func() {
|
||||
for p := range written {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
}
|
||||
for _, it := range append(items, last...) {
|
||||
data := it.data
|
||||
if data == nil {
|
||||
if data, err = os.ReadFile(filepath.Join(from, it.rel)); err != nil {
|
||||
undo()
|
||||
return res, fmt.Errorf("cannot read %s: %w. Nothing was removed from %s", filepath.Join(from, it.rel), err, from)
|
||||
}
|
||||
if strings.HasSuffix(it.rel, ".json") {
|
||||
data = rebaseJSONPaths(data, from, to)
|
||||
}
|
||||
}
|
||||
dst := filepath.Join(to, it.rel)
|
||||
if it.aside {
|
||||
dst += carryAsideSuffix
|
||||
}
|
||||
if have, err := os.ReadFile(dst); err == nil {
|
||||
if bytes.Equal(have, data) {
|
||||
continue
|
||||
}
|
||||
if !it.aside {
|
||||
res.Kept++
|
||||
dst += carryAsideSuffix
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
undo()
|
||||
return res, fmt.Errorf("%s is already there; the account's %s was not put beside the service's. "+
|
||||
"Nothing was removed from %s", dst, it.rel, from)
|
||||
}
|
||||
res.Aside = append(res.Aside, dst)
|
||||
} else {
|
||||
undo()
|
||||
return res, fmt.Errorf("%s is already there; the account's %s was not put beside it. Nothing was "+
|
||||
"removed from %s", dst, it.rel, from)
|
||||
}
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
undo()
|
||||
return res, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
undo()
|
||||
return res, err
|
||||
}
|
||||
if err := carryWriteFile(dst, data, 0o600); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
undo()
|
||||
return res, fmt.Errorf("could not write %s: %w. Nothing was removed from %s", dst, err, from)
|
||||
}
|
||||
written[dst] = data
|
||||
}
|
||||
for p, want := range written {
|
||||
got, err := os.ReadFile(p)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
undo()
|
||||
return res, fmt.Errorf("%s did not read back as written; nothing was removed from %s", p, from)
|
||||
}
|
||||
}
|
||||
got, err := pairingHosts(to)
|
||||
if err != nil {
|
||||
undo()
|
||||
return res, err
|
||||
}
|
||||
in := map[string]bool{}
|
||||
for _, h := range got {
|
||||
in[h] = true
|
||||
}
|
||||
for _, h := range append(append([]string{}, have...), add...) {
|
||||
if !in[h] {
|
||||
undo()
|
||||
return res, fmt.Errorf("the service's folder %s holds the pairings %v after the copy, not %s's; nothing "+
|
||||
"was removed from %s", to, got, h, from)
|
||||
}
|
||||
}
|
||||
res.Hosts, res.Beside, res.Pending, res.Stayed, res.Anchor = add, have, anchored, stay, anchorName
|
||||
|
||||
// From here the service has every added pairing, so what will not go is
|
||||
// said, not a failure.
|
||||
var leftErr error
|
||||
if len(stay) > 0 {
|
||||
var rels []string
|
||||
for r := range carried {
|
||||
rels = append(rels, r)
|
||||
}
|
||||
sort.Strings(rels)
|
||||
for _, r := range rels {
|
||||
if err := carryRemoveAll(filepath.Join(from, r)); err != nil && leftErr == nil {
|
||||
leftErr = err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ents, err := os.ReadDir(from)
|
||||
if err != nil {
|
||||
leftErr = err
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.Name() == "run.lock" {
|
||||
continue
|
||||
}
|
||||
if err := carryRemoveAll(filepath.Join(from, e.Name())); err != nil && leftErr == nil {
|
||||
leftErr = err
|
||||
}
|
||||
}
|
||||
unlock()
|
||||
if err := carryRemoveAll(from); err != nil && leftErr == nil {
|
||||
leftErr = err
|
||||
}
|
||||
}
|
||||
if leftErr != nil {
|
||||
res.Left = fmt.Sprintf("part of %s could not be removed (%v). It may still hold copies of the added pairings' "+
|
||||
"keys: delete those files", from, leftErr)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
+192
-26
@@ -94,32 +94,6 @@ func TestAServiceInstallCarriesTheAccountsPairingsIntoTheService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A service that already serves a Host is never merged into, and the
|
||||
// account's state stays where it was.
|
||||
func TestACarryNeverMergesIntoAServiceThatServesAHost(t *testing.T) {
|
||||
from, to := carryTree(t)
|
||||
p := filepath.Join(to, "pairings", "box-two.example.test.json")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := carryStateInto(from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Hosts) != 0 || !strings.Contains(res.Note, "box-two.example.test") {
|
||||
t.Fatalf("carried into a serving service: %+v", res)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
|
||||
t.Errorf("the account's pairing was touched: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(to, "pairings", "box-one.example.test.json")); err == nil {
|
||||
t.Error("the account's pairing was merged in")
|
||||
}
|
||||
}
|
||||
|
||||
// Not while a bastion still runs from the folder: its next write would be
|
||||
// lost, and two bastions would hold the same keys.
|
||||
func TestACarryWaitsForTheAccountsBastionToStop(t *testing.T) {
|
||||
@@ -351,3 +325,195 @@ func TestACarryWhoseSourceCannotBeRemovedStillSucceeds(t *testing.T) {
|
||||
t.Errorf("the install does not say the account's copy is still there: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCarryFile writes body at rel under dir.
|
||||
func writeCarryFile(t *testing.T, dir, rel, body string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// addHostsTree is carryTree with the account's pairing on the first control
|
||||
// port, and a service that already serves box-two on that same port.
|
||||
func addHostsTree(t *testing.T) (from, to string) {
|
||||
t.Helper()
|
||||
from, to = carryTree(t)
|
||||
writeCarryFile(t, from, "pairings/box-one.example.test.json", `{"host_name":"box-one.example.test","bearer":"mcp_test",`+
|
||||
`"listen_addr":"127.0.0.1:7878","git_proxy_addr":"127.0.0.1:7978",`+
|
||||
`"context_root":"`+filepath.ToSlash(filepath.Join(from, "context", "box-one.example.test"))+`",`+
|
||||
`"dcn_dir":"`+filepath.ToSlash(filepath.Join(from, "dcn", "box-one.example.test"))+`"}`)
|
||||
writeCarryFile(t, to, "pairings/box-two.example.test.json", `{"host_name":"box-two.example.test",`+
|
||||
`"listen_addr":"127.0.0.1:7878","git_proxy_addr":"127.0.0.1:7978"}`)
|
||||
writeCarryFile(t, to, "ui.secret", "service secret")
|
||||
return from, to
|
||||
}
|
||||
|
||||
// With no question asked, the account's pairing for a Host the service does
|
||||
// not serve is added beside the service's: its keys as they were, its
|
||||
// folders the service's, a control port of its own. Nothing machine-wide of
|
||||
// the account's takes the service's place; it is kept beside it.
|
||||
//
|
||||
// MUTATION: leave the account's pairings where they are when the service
|
||||
// serves a Host (the 0.62 rule). RESULT: red. Keep the account's listen_addr
|
||||
// in carriedPairing. RESULT: red (two
|
||||
// pairings on 127.0.0.1:7878). Write the account's other files without
|
||||
// carryAsideSuffix. RESULT: red (the account's ui.secret becomes the service's).
|
||||
func TestAMoveAddsTheAccountsOtherHostBesideTheServices(t *testing.T) {
|
||||
from, to := addHostsTree(t)
|
||||
writeCarryFile(t, from, "mode.json", `{"mode":"networked"}`)
|
||||
res, err := carryStateInto(from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(to, "mode.json")); err == nil {
|
||||
t.Error("the account's mode became the machine's, which had none of its own")
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "mode.json"+carryAsideSuffix)); !strings.Contains(string(b), "networked") {
|
||||
t.Errorf("the account's mode was not kept beside: %q", b)
|
||||
}
|
||||
if strings.Join(res.Hosts, ",") != "box-one.example.test" || strings.Join(res.Beside, ",") != "box-two.example.test" {
|
||||
t.Fatalf("added %v beside %v (%s)", res.Hosts, res.Beside, res.Note)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(to, "pairings", "box-one.example.test.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the added pairing is not in the service: %v", err)
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Bearer != "mcp_test" || c.State != "" {
|
||||
t.Errorf("the pairing changed on the way: %+v", c)
|
||||
}
|
||||
if c.ListenAddr == "127.0.0.1:7878" || c.ListenAddr == "" || c.GitProxyAddr == "127.0.0.1:7978" || c.GitProxyAddr == c.ListenAddr {
|
||||
t.Errorf("the added pairing shares a port with the service's: listen %q, git %q", c.ListenAddr, c.GitProxyAddr)
|
||||
}
|
||||
if !samePath(c.ContextRoot, filepath.Join(to, "context", "box-one.example.test")) {
|
||||
t.Errorf("the added pairing's context is still the account's: %q", c.ContextRoot)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "dcn", "box-one.example.test", "grants.json")); !strings.Contains(string(b), "notes") {
|
||||
t.Errorf("the added pairing's grants did not come along: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "pairings", "box-two.example.test.json")); !strings.Contains(string(b), "7878") {
|
||||
t.Errorf("the service's own pairing changed: %s", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" {
|
||||
t.Errorf("the service's own secret was replaced: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "ui.secret"+carryAsideSuffix)); string(b) != "account secret" {
|
||||
t.Errorf("the account's secret was not kept beside the service's: %q", b)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(to, "run.holder.json")); err == nil {
|
||||
t.Error("a running bastion's file came along")
|
||||
}
|
||||
if _, err := os.Stat(from); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("the account's folder, whose pairings all moved, is still there: %v", err)
|
||||
}
|
||||
if line := carryReport(res, from); !strings.Contains(line, "Added box-one.example.test to the service, beside box-two.example.test") {
|
||||
t.Errorf("the report: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// On an anchored machine an added pairing waits for the anchor, as any new
|
||||
// pairing there does; the account's grant was another anchor's and is kept
|
||||
// beside, never as this machine's.
|
||||
//
|
||||
// MUTATION: drop the pending state in carriedPairing. RESULT: red.
|
||||
func TestAnAddedHostWaitsForTheAnchor(t *testing.T) {
|
||||
from, to := addHostsTree(t)
|
||||
writeCarryFile(t, to, "anchor.json", `{"owner":"box-two","anchor_host":"box-two.example.test"}`)
|
||||
writeCarryFile(t, from, "pairings/box-one.example.test"+pairingGrantSuffix, `{"grant":"another anchor's"}`)
|
||||
res, err := carryStateInto(from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(to, "pairings", "box-one.example.test.json"))
|
||||
var c Config
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
if c.State != pairingPending || !res.Pending || c.Origin != pairingOriginCarried {
|
||||
t.Errorf("the added pairing does not wait for the anchor as a carried one: state %q origin %q, %+v", c.State, c.Origin, res)
|
||||
}
|
||||
if line := carryReport(res, from); !strings.Contains(line, "waits until box-two.example.test, the Host in charge of this machine, admits it") {
|
||||
t.Errorf("the report does not say who admits it: %q", line)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(to, "pairings", "box-one.example.test"+pairingGrantSuffix)); err == nil {
|
||||
t.Error("another anchor's grant became this machine's")
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "anchor.json")); !strings.Contains(string(b), "box-two") {
|
||||
t.Errorf("the machine's anchor changed: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// The account's pairing for a Host the service already serves stays, and so
|
||||
// does everything that is not an added Host's: only what was added leaves.
|
||||
func TestAnAddLeavesThePairingForAHostTheServiceServes(t *testing.T) {
|
||||
from, to := addHostsTree(t)
|
||||
writeCarryFile(t, from, "pairings/box-two.example.test.json", `{"host_name":"box-two.example.test","bearer":"mcp_account"}`)
|
||||
res, err := carryStateInto(from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(res.Hosts, ",") != "box-one.example.test" || strings.Join(res.Stayed, ",") != "box-two.example.test" {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(to, "pairings", "box-two.example.test.json")); strings.Contains(string(b), "mcp_account") {
|
||||
t.Error("the account's pairing replaced the service's for the same Host")
|
||||
}
|
||||
for _, rel := range []string{"pairings/box-two.example.test.json", "ui.secret"} {
|
||||
if _, err := os.Stat(filepath.Join(from, filepath.FromSlash(rel))); err != nil {
|
||||
t.Errorf("%s left the account's folder: %v", rel, err)
|
||||
}
|
||||
}
|
||||
for _, rel := range []string{"pairings/box-one.example.test.json", "dcn/box-one.example.test", "context/box-one.example.test"} {
|
||||
if _, err := os.Stat(filepath.Join(from, filepath.FromSlash(rel))); err == nil {
|
||||
t.Errorf("%s, which moved, is still in the account's folder", rel)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(to, "ui.secret"+carryAsideSuffix)); err == nil {
|
||||
t.Error("the account's other files moved while a pairing of its stays")
|
||||
}
|
||||
}
|
||||
|
||||
// A pairing that keeps its state in a folder every pairing shares (before
|
||||
// 0.10.0) is not moved: that folder is not its alone.
|
||||
func TestAnAddRefusesAPairingInTheSharedFolders(t *testing.T) {
|
||||
from := filepath.Join(t.TempDir(), "privatae-bastion")
|
||||
raw := []byte(`{"host_name":"old.example.test","context_root":"` + filepath.ToSlash(filepath.Join(from, "context")) + `"}`)
|
||||
if _, err := carryHostRoots(raw, from, "old.example.test"); err == nil {
|
||||
t.Error("a pairing in the shared context folder would be moved")
|
||||
}
|
||||
raw = []byte(`{"host_name":"new.example.test","backup_root":"/mnt/nas","runs_dir":"` +
|
||||
filepath.ToSlash(filepath.Join(from, "runs", "new")) + `"}`)
|
||||
roots, err := carryHostRoots(raw, from, "new.example.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(roots, ","), filepath.Join("runs", "new")) || strings.Contains(strings.Join(roots, ","), "nas") {
|
||||
t.Errorf("roots %v", roots)
|
||||
}
|
||||
}
|
||||
|
||||
// Under an organisation's policy, a carried Host it does not name waits for the
|
||||
// one it names, even on a machine nobody holds yet.
|
||||
//
|
||||
// MUTATION: drop the policy clause in carriedPairing. RESULT: red.
|
||||
func TestACarriedHostWaitsForTheHostAPolicyNames(t *testing.T) {
|
||||
prev := readAnchorPolicy
|
||||
readAnchorPolicy = func() *anchorPolicy { return &anchorPolicy{Host: "corp.example.test", Source: "test"} }
|
||||
t.Cleanup(func() { readAnchorPolicy = prev })
|
||||
from, to := addHostsTree(t)
|
||||
if _, err := carryStateInto(from, to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(to, "pairings", "box-one.example.test.json"))
|
||||
var c Config
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
if c.State != pairingPending {
|
||||
t.Errorf("a Host the policy does not name came in %q", c.State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,7 @@ const sessionWorkerCount = 4
|
||||
// self is the pairing this pool belongs to, for the caps gate on an anchored box.
|
||||
func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls.Config,
|
||||
dialHost func(ctx context.Context) (net.Conn, error), bearer string, n int, senv sessionEnv) {
|
||||
dialer := &websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) },
|
||||
TLSClientConfig: tlsCfg,
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
}
|
||||
dialer := laneWSDialer(tlsCfg, dialHost)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
hold := laneHoldFor(self)
|
||||
|
||||
@@ -143,9 +143,10 @@ func TestARunThatEndedWritesNothing(t *testing.T) {
|
||||
if !startPairing(cfg) {
|
||||
t.Fatal("setup: the pairing was not started")
|
||||
}
|
||||
select {
|
||||
case <-errc: // runPairing refused the address and returned
|
||||
case <-time.After(5 * time.Second):
|
||||
// runPairing refused the address and returned; that pairing alone stopped
|
||||
// (pairing_retry.go).
|
||||
_ = errc
|
||||
if waitPairingFailure(t, host) == "" {
|
||||
t.Fatal("setup: the run did not end")
|
||||
}
|
||||
cfg.Endpoint = "203.0.113.99:1"
|
||||
@@ -181,12 +182,25 @@ func TestSuperviseRestartsAPairingMadeAgainWithItsNewFile(t *testing.T) {
|
||||
if old.Err() == nil {
|
||||
t.Fatal("the run holding the replaced key is still up")
|
||||
}
|
||||
select {
|
||||
case err := <-errc:
|
||||
if !strings.Contains(err.Error(), `"from-the-new-file"`) {
|
||||
t.Fatalf("a run started, but not from the new file: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = errc
|
||||
switch got := waitPairingFailure(t, host); {
|
||||
case got == "":
|
||||
t.Fatal("no run was started with the new file")
|
||||
case !strings.Contains(got, `"from-the-new-file"`):
|
||||
t.Fatalf("a run started, but not from the new file: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// waitPairingFailure waits (bounded) for host's run to stop on an error, and
|
||||
// returns it; "" when none came. A run's error stops that pairing alone and is
|
||||
// recorded (pairing_retry.go), no longer sent to the daemon's fatal channel.
|
||||
func waitPairingFailure(t *testing.T, host string) string {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { pairingRecovered(host) })
|
||||
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
|
||||
if f, ok := loadPairingFailures()[host]; ok {
|
||||
return f.Error
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -167,3 +167,7 @@ func writeRootFile(p string, data []byte, mode os.FileMode) error {
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
|
||||
// systemServiceRunning: the launchd daemon runs `run` at once, whose console
|
||||
// answers for it (pair.go pairDaemonRunning).
|
||||
func systemServiceRunning() bool { return false }
|
||||
|
||||
@@ -18,3 +18,7 @@ func installSystemService() error { return errLinuxServiceIsTheUnit }
|
||||
func uninstallSystemService() error { return errLinuxServiceIsTheUnit }
|
||||
|
||||
func printSystemServiceStatus() error { return errLinuxServiceIsTheUnit }
|
||||
|
||||
// systemServiceRunning: no service manager of ours here; the console answers
|
||||
// for a running bastion (pair.go pairDaemonRunning).
|
||||
func systemServiceRunning() bool { return false }
|
||||
|
||||
@@ -158,7 +158,7 @@ func (bastionService) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- s
|
||||
s <- c.CurrentStatus
|
||||
case svc.Stop, svc.Shutdown:
|
||||
s <- svc.Status{State: svc.StopPending}
|
||||
requestShutdown()
|
||||
requestShutdownFor(shutdownFromService)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
@@ -440,3 +440,12 @@ func printSystemServiceStatus() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemServiceRunning: the service runs now. A service waiting for its first
|
||||
// pairing has no console yet, and still serves a pairing written beside it
|
||||
// within moments (runSystemService waitForPairing), so `pair` asks this as
|
||||
// well as the console.
|
||||
func systemServiceRunning() bool {
|
||||
pid, err := bastionServicePID()
|
||||
return err == nil && pid != 0
|
||||
}
|
||||
|
||||
+8
-1
@@ -19,10 +19,15 @@ $dir = Join-Path ([IO.Path]::GetTempPath()) ("o'brien-o$([char]0x2019)neil two-
|
||||
New-Item -ItemType Directory -Path $dir | Out-Null
|
||||
$env:LOCALAPPDATA = $dir
|
||||
$env:TMPDIR = $dir
|
||||
$env:ProgramFiles = $dir
|
||||
function Write-Host { param($Object) $script:out += "$Object`n" }
|
||||
|
||||
function Run-Case([string]$answer, [bool]$installed, [string]$pair) {
|
||||
$script:out = ''; $script:cmd = ''; $script:pairSeen = ''
|
||||
$script:tray = ''
|
||||
# The tray is started again from the new program after a yes (Restart-Tray,
|
||||
# run by testdata/install_ps1_swap.ps1).
|
||||
function Restart-Tray { param($exe) $script:tray = $exe; return $true }
|
||||
$Service = $true; $isAdmin = $false; $serviceInstalled = $installed; $Shared = $false
|
||||
$elevatedDone = $false; $fellBack = $false; $Pair = $pair
|
||||
$installScript = 'https://privatae.ai/bastion/install.ps1'
|
||||
@@ -58,7 +63,7 @@ function Run-Case([string]$answer, [bool]$installed, [string]$pair) {
|
||||
$threw = $null
|
||||
try { . $block } catch { $threw = $_.Exception.Message }
|
||||
return @{ threw = $threw; out = $script:out; cmd = $script:cmd; pairSeen = $script:pairSeen;
|
||||
service = $Service; done = $elevatedDone; fellBack = $fellBack }
|
||||
service = $Service; done = $elevatedDone; fellBack = $fellBack; tray = $script:tray }
|
||||
}
|
||||
function Fail([string]$why) { Write-Output "FAIL: $why"; exit 1 }
|
||||
|
||||
@@ -69,9 +74,11 @@ if ($r.pairSeen -ne 'skp_INVENTEDCODE') { Fail "the elevated step could not read
|
||||
if ($r.cmd -notmatch "-Personal -OwnerSid 'S-1-5-21-1-2-3-1001'") { Fail "kind or owner: $($r.cmd)" }
|
||||
if ($r.cmd -notmatch "o''brien-o$([char]0x2019)$([char]0x2019)neil") { Fail "the temp folder's quotes are not doubled: $($r.cmd)" }
|
||||
if ($r.out -notmatch 'Installed: C:\\Program Files') { Fail "the elevated step's output is not shown: $($r.out)" }
|
||||
if ($r.tray -ne (Join-Path $dir 'Privatae\privatae-bastion.exe')) { Fail "the tray was not started again from the installed program: '$($r.tray)'" }
|
||||
if (Get-ChildItem -LiteralPath $dir -Filter 'privatae-bastion-pair-*' -ErrorAction SilentlyContinue) { Fail 'the code file was left behind' }
|
||||
|
||||
$r = Run-Case 'no' $false ''
|
||||
if ($r.tray) { Fail 'a declined install restarted the tray' }
|
||||
if ($r.threw -or $r.done -or $r.service -or -not $r.fellBack -or $r.out -notmatch 'installed for your account only') { Fail "a no must fall back to this account: $($r.threw) $($r.out)" }
|
||||
|
||||
$r = Run-Case 'no' $true ''
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Runs install.ps1's Install-Program, Stop-InstallFailed and Restart-Tray with
|
||||
# Windows' services and processes stood in. pwsh on Linux:
|
||||
# pwsh -NoProfile -File testdata/install_ps1_swap.ps1 ../deploy/install.ps1
|
||||
# Exits 0 when every case holds, and prints the first that does not.
|
||||
param([string]$Script)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$src = Get-Content -Raw $Script
|
||||
foreach ($name in 'Install-Program', 'Stop-InstallFailed', 'Restart-Tray') {
|
||||
$m = [regex]::Match($src, "(?ms)^function $name\(.*?^\}\r?\n")
|
||||
if (-not $m.Success) { Write-Output "FAIL: install.ps1 has no $name"; exit 1 }
|
||||
. ([scriptblock]::Create($m.Value))
|
||||
}
|
||||
function Fail([string]$why) { Write-Output "FAIL: $why"; exit 1 }
|
||||
|
||||
$dir = Join-Path ([IO.Path]::GetTempPath()) ("install-swap-" + [guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $dir | Out-Null
|
||||
$exe = Join-Path $dir 'privatae-bastion.exe'
|
||||
$new = Join-Path $dir 'download.exe'
|
||||
|
||||
# The program in place moves aside to .old and the new one takes its name.
|
||||
Set-Content -LiteralPath $exe -Value 'old'
|
||||
Set-Content -LiteralPath "$exe.old" -Value 'older'
|
||||
Set-Content -LiteralPath $new -Value 'new'
|
||||
Install-Program $new $exe
|
||||
if ((Get-Content -LiteralPath $exe) -ne 'new') { Fail 'the new program is not in place' }
|
||||
if ((Get-Content -LiteralPath "$exe.old") -ne 'old') { Fail 'the earlier program was not moved aside to .old' }
|
||||
if (Test-Path -LiteralPath $new) { Fail 'the download was left behind' }
|
||||
|
||||
# A new file that cannot be moved in puts the earlier program back.
|
||||
$threw = $false
|
||||
try { Install-Program (Join-Path $dir 'missing.exe') $exe } catch { $threw = $true }
|
||||
if (-not $threw) { Fail 'a failed move did not fail the install' }
|
||||
if ((Get-Content -LiteralPath $exe) -ne 'new') { Fail 'a failed move left no program in place' }
|
||||
|
||||
# A first install has nothing to move aside.
|
||||
$fresh = Join-Path $dir 'fresh.exe'
|
||||
Set-Content -LiteralPath $new -Value 'new'
|
||||
Install-Program $new $fresh
|
||||
if ((Get-Content -LiteralPath $fresh) -ne 'new' -or (Test-Path -LiteralPath "$fresh.old")) { Fail 'a first install' }
|
||||
|
||||
# A stopped service is started again before the failure is reported.
|
||||
$script:started = 0
|
||||
function Start-Service { param($Name, $ErrorAction) $script:started++ }
|
||||
try { Stop-InstallFailed $true 'It failed.' } catch { $msg = $_.Exception.Message }
|
||||
if ($script:started -ne 1 -or $msg -notmatch 'It failed\. The bastion''s service is running again\.') { Fail "restart after a failure: $script:started, $msg" }
|
||||
function Start-Service { param($Name, $ErrorAction) throw 'access denied' }
|
||||
try { Stop-InstallFailed $true 'It failed.' } catch { $msg = $_.Exception.Message }
|
||||
if ($msg -notmatch 'could not be started again \(access denied\)' -or $msg -notmatch 'Start-Service PrivataeBastion') { Fail "a restart that fails: $msg" }
|
||||
$script:started = 0
|
||||
function Start-Service { param($Name, $ErrorAction) $script:started++ }
|
||||
try { Stop-InstallFailed $false 'It failed.' } catch { $msg = $_.Exception.Message }
|
||||
if ($script:started -ne 0 -or $msg -ne 'It failed.') { Fail "a service this install did not stop was started: $msg" }
|
||||
|
||||
# Only this session's trays from the installed program are stopped, and the
|
||||
# tray is started from the new one.
|
||||
$me = (Get-Process -Id $PID).SessionId
|
||||
$script:stopped = @(); $script:launched = $null
|
||||
function Get-CimInstance { param($ClassName, $Filter, $ErrorAction)
|
||||
@([pscustomobject]@{ ProcessId = 11; SessionId = $me; ExecutablePath = "$exe.old"; CommandLine = "`"$exe`" tray" },
|
||||
[pscustomobject]@{ ProcessId = 12; SessionId = $me + 1; ExecutablePath = $exe; CommandLine = "`"$exe`" tray" },
|
||||
[pscustomobject]@{ ProcessId = 13; SessionId = $me; ExecutablePath = $exe; CommandLine = "`"$exe`" run" },
|
||||
[pscustomobject]@{ ProcessId = 14; SessionId = $me; ExecutablePath = 'C:\elsewhere\privatae-bastion.exe'; CommandLine = 'x tray' }) }
|
||||
function Stop-Process { param($Id, [switch]$Force, $ErrorAction) $script:stopped += $Id }
|
||||
function Wait-Process { param($Id, $Timeout, $ErrorAction) }
|
||||
function Start-Process { param($FilePath, $ArgumentList) $script:launched = "$FilePath $ArgumentList" }
|
||||
if (-not (Restart-Tray $exe)) { Fail 'the tray was not started' }
|
||||
if (($script:stopped -join ',') -ne '11') { Fail "stopped: $($script:stopped -join ',')" }
|
||||
if ($script:launched -ne "$exe tray") { Fail "started: $script:launched" }
|
||||
|
||||
# The service block that follows the swap: the service is stopped by then, so
|
||||
# a logon task that cannot be removed starts it again, and the trays running
|
||||
# from the program moved aside are not stopped as an earlier install.
|
||||
$m = [regex]::Match($src, '(?ms)^ if \(\$Service\) \{\r?\n \$task = .*?(?=^ \} else \{)')
|
||||
if (-not $m.Success) { Write-Output 'FAIL: install.ps1 has no service block'; exit 1 }
|
||||
$serviceBlock = [scriptblock]::Create($m.Value + "}`n")
|
||||
function Write-Host { param($Object) }
|
||||
$Service = $true; $OwnerSid = 'S-1-5-21-1-2-3-1001'; $stoppedService = $true
|
||||
$exe = Join-Path $dir 'privatae-bastion.exe'
|
||||
function Get-ScheduledTask { param($TaskName, $ErrorAction) [pscustomobject]@{ TaskName = $TaskName } }
|
||||
function Unregister-ScheduledTask { param($TaskName, $Confirm) throw 'access denied' }
|
||||
$script:started = 0; $msg = $null
|
||||
function Start-Service { param($Name, $ErrorAction) $script:started++ }
|
||||
try { . $serviceBlock } catch { $msg = $_.Exception.Message }
|
||||
if ($script:started -ne 1 -or $msg -notmatch 'could not be removed \(access denied\)' -or $msg -notmatch 'service is running again') {
|
||||
Fail "a logon task that cannot be removed left the service stopped: $script:started, $msg"
|
||||
}
|
||||
function Unregister-ScheduledTask { param($TaskName, $Confirm) }
|
||||
$script:stopped = @()
|
||||
function Get-Process { param($Name, $Id, $ErrorAction)
|
||||
@([pscustomobject]@{ Id = 21; Path = "$exe.old" }, [pscustomobject]@{ Id = 22; Path = "$exe.bak" },
|
||||
[pscustomobject]@{ Id = 23; Path = $exe }, [pscustomobject]@{ Id = 24; Path = 'C:\Users\a\AppData\Local\Programs\privatae-bastion\privatae-bastion.exe' }) }
|
||||
function Stop-Process { param($Id, [switch]$Force, $ErrorAction) $script:stopped += $Id }
|
||||
# A stand-in bastion whose `service install` succeeds.
|
||||
Set-Content -LiteralPath $exe -Value "#!/bin/sh`nexit 0"
|
||||
if ($IsLinux) { chmod +x $exe }
|
||||
try { . $serviceBlock } catch { Fail "the service block failed: $($_.Exception.Message)" }
|
||||
if (($script:stopped -join ',') -ne '24') { Fail "stopped as an earlier install: $($script:stopped -join ',')" }
|
||||
|
||||
Remove-Item -Recurse -Force $dir
|
||||
Write-Output 'ok: the program is moved aside, put back on failure, the service restarted, and the tray restarted'
|
||||
+38
-9
@@ -33,11 +33,29 @@ const trayRefresh = 5 * time.Second
|
||||
var (
|
||||
shutdownOnce sync.Once
|
||||
shutdownCh = make(chan struct{})
|
||||
// shutdownWhy is who asked, as cmdRun logs it; set once, with the close.
|
||||
shutdownWhy = "asked from the tray"
|
||||
)
|
||||
|
||||
// requestShutdown asks cmdRun to stop, exactly as Ctrl-C or SIGTERM does. Once
|
||||
// only — closing a closed channel panics, and a menu item can be clicked twice.
|
||||
func requestShutdown() { shutdownOnce.Do(func() { close(shutdownCh) }) }
|
||||
// The askers requestShutdownFor names.
|
||||
const (
|
||||
shutdownFromTray = "asked from the tray"
|
||||
shutdownFromService = "the Windows service manager asked it to stop (Stop-Service, Restart-Service, an update or Windows shutting down)"
|
||||
)
|
||||
|
||||
// requestShutdown asks cmdRun to stop, exactly as Ctrl-C or SIGTERM does, for
|
||||
// the tray's "Stop the bastion". Once only — closing a closed channel panics,
|
||||
// and a menu item can be clicked twice.
|
||||
func requestShutdown() { requestShutdownFor(shutdownFromTray) }
|
||||
|
||||
// requestShutdownFor is requestShutdown for another asker, named in the log
|
||||
// (the service manager's stop was logged as the tray's).
|
||||
func requestShutdownFor(why string) {
|
||||
shutdownOnce.Do(func() {
|
||||
shutdownWhy = why
|
||||
close(shutdownCh)
|
||||
})
|
||||
}
|
||||
|
||||
// openLocalUI is what every "take me to the page" affordance does: the tray's
|
||||
// menu item, and a click on an approval notification (notify_windows.go).
|
||||
@@ -74,16 +92,27 @@ func trayStateLine(s uiStatus) string {
|
||||
case s.AnchorRequired:
|
||||
return "Not anchored — answering nothing"
|
||||
default:
|
||||
n := 0
|
||||
n, servable, waiting, who := 0, 0, 0, ""
|
||||
for _, p := range s.Pairings {
|
||||
if p.Listening {
|
||||
n++
|
||||
if p.WaitingFor != "" {
|
||||
waiting, who = waiting+1, p.WaitingFor
|
||||
continue
|
||||
}
|
||||
if p.State == "" || p.State == pairingActive {
|
||||
servable++
|
||||
if p.Listening {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == len(s.Pairings) {
|
||||
return fmt.Sprintf("Serving %d host(s)", n)
|
||||
line := fmt.Sprintf("Serving %d of %d hosts", n, servable)
|
||||
if n == servable {
|
||||
line = fmt.Sprintf("Serving %d host(s)", n)
|
||||
}
|
||||
return fmt.Sprintf("Serving %d of %d hosts", n, len(s.Pairings))
|
||||
if waiting > 0 {
|
||||
line += fmt.Sprintf("; %d waiting for %s", waiting, who)
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-9
@@ -78,6 +78,9 @@ type uiPairing struct {
|
||||
TunnelIP string
|
||||
FirstSeen string
|
||||
IsAnchor bool
|
||||
// WaitingFor: pending, waiting for this Host (the one in charge of the
|
||||
// machine) to admit it; nothing runs for it until then.
|
||||
WaitingFor string
|
||||
}
|
||||
|
||||
// uiApproval is one thing a host has asked this machine to allow, waiting for
|
||||
@@ -240,7 +243,8 @@ func collectUIStatus() uiStatus {
|
||||
s.StateNote = err.Error()
|
||||
return s
|
||||
}
|
||||
running := 0
|
||||
running, servable, waiting := 0, 0, 0
|
||||
who := admitter()
|
||||
for _, c := range cfgs {
|
||||
p := uiPairing{
|
||||
Host: c.HostName,
|
||||
@@ -253,30 +257,44 @@ func collectUIStatus() uiStatus {
|
||||
FirstSeen: c.FirstSeen,
|
||||
IsAnchor: s.AnchorHost != "" && c.HostName == s.AnchorHost,
|
||||
}
|
||||
if p.Listening {
|
||||
running++
|
||||
if c.invalid == "" && c.State == pairingPending && who != "" {
|
||||
p.WaitingFor = who
|
||||
waiting++
|
||||
}
|
||||
if c.active() {
|
||||
servable++
|
||||
if p.Listening {
|
||||
running++
|
||||
}
|
||||
}
|
||||
s.Pairings = append(s.Pairings, p)
|
||||
s.Approvals = append(s.Approvals, pendingApprovals(c)...)
|
||||
}
|
||||
|
||||
wait := ""
|
||||
if waiting > 0 {
|
||||
wait = fmt.Sprintf(" %d more wait(s) for %s to admit them: %s asks you to approve each.", waiting, who, who)
|
||||
}
|
||||
switch {
|
||||
case len(cfgs) == 0:
|
||||
// No Host is a steady state, not a fault (run.go): the bastion watches
|
||||
// this machine on its own.
|
||||
s.State, s.StateClass = "ON ITS OWN", "ok"
|
||||
s.StateNote = "No Host is connected. The bastion watches the AI agents on this machine on its own."
|
||||
case running == len(cfgs):
|
||||
case servable == 0 && waiting > 0:
|
||||
s.State, s.StateClass = "WAITING", "warn"
|
||||
s.StateNote = strings.TrimSpace(fmt.Sprintf("No Host is served yet.%s", wait))
|
||||
case servable > 0 && running == servable:
|
||||
s.State, s.StateClass = "RUNNING", "ok"
|
||||
s.StateNote = fmt.Sprintf("Serving %d host(s) from this machine.", len(cfgs))
|
||||
s.NoConsoleNote = runtime.GOOS == "windows"
|
||||
s.StateNote = fmt.Sprintf("Serving %d host(s) from this machine.%s", servable, wait)
|
||||
s.NoConsoleNote = runtime.GOOS == "windows" && !systemService.installed
|
||||
case running > 0:
|
||||
s.State, s.StateClass = "PARTLY RUNNING", "warn"
|
||||
s.StateNote = fmt.Sprintf("%d of %d hosts are being served; the rest are not answering.",
|
||||
running, len(cfgs))
|
||||
s.StateNote = fmt.Sprintf("%d of %d hosts are being served; the rest are not answering.%s",
|
||||
running, servable, wait)
|
||||
default:
|
||||
s.State, s.StateClass = "NOT RUNNING", "bad"
|
||||
s.StateNote = "Nothing is serving these pairings. Start it with `" + runCommandHint() + "`."
|
||||
s.StateNote = "Nothing is serving these pairings. Start it with `" + runCommandHint() + "`." + wait
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ func TestRenderAPreview(t *testing.T) {
|
||||
d, _ := pairingsDir()
|
||||
_ = os.MkdirAll(d, 0o700)
|
||||
for _, p := range []struct{ host, addr, state string }{
|
||||
{"259c83f46775ec66.host.privatae.ai", "127.0.0.1:7878", pairingActive},
|
||||
{"8abb770bbfde71d0.host.privatae.ai", "127.0.0.1:7879", pairingActive},
|
||||
{"99dab9c47d60edff.host.privatae.ai", "127.0.0.1:7880", pairingPending},
|
||||
{"0a1b2c3d4e5f6071.host.privatae.ai", "127.0.0.1:7878", pairingActive},
|
||||
{"1b2c3d4e5f607182.host.privatae.ai", "127.0.0.1:7879", pairingActive},
|
||||
{"2c3d4e5f60718293.host.privatae.ai", "127.0.0.1:7880", pairingPending},
|
||||
} {
|
||||
b := []byte(`{"host_name":"` + p.host + `","listen_addr":"` + p.addr +
|
||||
`","bastion_tunnel_ip":"fd00:9::a:1","state":"` + p.state +
|
||||
@@ -38,7 +38,7 @@ func TestRenderAPreview(t *testing.T) {
|
||||
// the page that is actually waiting on somebody.
|
||||
dcn := filepath.Join(t.TempDir(), "dcn")
|
||||
_ = os.MkdirAll(dcn, 0o700)
|
||||
first := filepath.Join(d, "259c83f46775ec66.host.privatae.ai.json")
|
||||
first := filepath.Join(d, "0a1b2c3d4e5f6071.host.privatae.ai.json")
|
||||
if b, err := os.ReadFile(first); err == nil {
|
||||
s := strings.TrimSuffix(string(b), "}") + `,"dcn_dir":` + jsonQuote(dcn) + `}`
|
||||
_ = os.WriteFile(first, []byte(s), 0o600)
|
||||
|
||||
@@ -62,14 +62,15 @@
|
||||
<div class="host-name">
|
||||
{{.Host}}
|
||||
{{if .IsAnchor}}<span class="tag ok">anchor</span>{{end}}
|
||||
{{if eq .State "pending"}}<span class="tag warn">waiting for approval</span>{{end}}
|
||||
{{if eq .State "pending"}}<span class="tag warn">{{if .WaitingFor}}waiting for {{.WaitingFor}} to admit it{{else}}waiting for approval{{end}}</span>{{end}}
|
||||
{{if eq .State "revoked"}}<span class="tag bad">revoked</span>{{end}}
|
||||
{{if eq .State "invalid"}}<span class="tag bad">invalid, not served</span>{{end}}
|
||||
</div>
|
||||
<dl class="host-facts">
|
||||
<dt>Local port</dt>
|
||||
<dd>{{.ListenAddr}}
|
||||
{{if .Listening}}<span class="tag ok">answering</span>
|
||||
{{if .WaitingFor}}<span class="tag warn">not served until {{.WaitingFor}} admits it ({{.WaitingFor}} asks you to approve it)</span>
|
||||
{{else if .Listening}}<span class="tag ok">answering</span>
|
||||
{{else}}<span class="tag bad">not answering</span>{{end}}</dd>
|
||||
{{if .TunnelIP}}<dt>Tunnel address</dt><dd>{{.TunnelIP}}</dd>{{end}}
|
||||
{{if .FirstSeen}}<dt>Paired since</dt><dd>{{.FirstSeen}}</dd>{{end}}
|
||||
|
||||
@@ -483,9 +483,12 @@ func planPairChange(before []string, after []*Config, cfg *Config) uiPairPlan {
|
||||
}
|
||||
}
|
||||
if !cfg.active() {
|
||||
p.Note = "Paired with " + cfg.HostName + " — PENDING. This machine is anchored, so a " +
|
||||
"new host waits until the anchor host approves it. Nothing is started for it yet: " +
|
||||
"no tunnel, no lanes. It shows as a pending pairing on the anchor host's Network app."
|
||||
p.Note = "Paired with " + cfg.HostName + " — PENDING. Nothing is started for it yet: no tunnel, no lanes."
|
||||
if who := admitter(); who != "" {
|
||||
p.Note = "Paired with " + cfg.HostName + " — PENDING, waiting for " + who + " to admit it. The code was used, " +
|
||||
"but nothing runs for it yet: " + who + " is the Host in charge of this machine, and it asks you to " +
|
||||
"approve " + cfg.HostName + ". Once you do, it is served at once."
|
||||
}
|
||||
p.Class = "warn"
|
||||
return p
|
||||
}
|
||||
|
||||
+23
-1
@@ -144,7 +144,28 @@ func cmdUninstall(deleteData bool) error {
|
||||
cfgs, _ := loadPairings()
|
||||
hadHosts := len(cfgs) > 0
|
||||
failed := 0
|
||||
for _, st := range uninstallSteps(runtime.GOOS, whole, home, os.Getuid()) {
|
||||
// The door's own firewall rules (doorfw.go): once the first step has
|
||||
// stopped the service, so nothing adds one back, and while the program a
|
||||
// rule names is still in place.
|
||||
fwDone := false
|
||||
removeFirewall := func() {
|
||||
if fwDone {
|
||||
return
|
||||
}
|
||||
fwDone = true
|
||||
fwSaid, fwErr := doorFirewallRemoveAll()
|
||||
for _, l := range fwSaid {
|
||||
fmt.Println(" " + l)
|
||||
}
|
||||
if fwErr != nil {
|
||||
fmt.Fprintf(os.Stderr, " could not remove the bastion's own firewall rule: %v\n", fwErr)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
for i, st := range uninstallSteps(runtime.GOOS, whole, home, os.Getuid()) {
|
||||
if i == 1 {
|
||||
removeFirewall()
|
||||
}
|
||||
switch {
|
||||
case st.Call == "system-service":
|
||||
if err := uninstallSystemService(); err != nil {
|
||||
@@ -171,6 +192,7 @@ func cmdUninstall(deleteData bool) error {
|
||||
fmt.Println(" " + st.Say)
|
||||
}
|
||||
}
|
||||
removeFirewall() // a platform with one step
|
||||
if deleteData {
|
||||
if err := os.RemoveAll(dataDir); err != nil {
|
||||
return fmt.Errorf("the bastion is removed, but its state could not be deleted: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user