From 4811f176fdaf59892c32cda5b2b11b29a179865d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 17:29:32 +0200 Subject: [PATCH] Refuse a converged genesis on a machine in use, naming every container and listener counted (hq ADR 0100) --- internal/bootstrap/inuse.go | 113 +++++++++++++++++++++++++++++++ internal/bootstrap/inuse_test.go | 99 +++++++++++++++++++++++++++ internal/bootstrap/preflight.go | 5 ++ 3 files changed, 217 insertions(+) create mode 100644 internal/bootstrap/inuse.go create mode 100644 internal/bootstrap/inuse_test.go diff --git a/internal/bootstrap/inuse.go b/internal/bootstrap/inuse.go new file mode 100644 index 0000000..3909de6 --- /dev/null +++ b/internal/bootstrap/inuse.go @@ -0,0 +1,113 @@ +package bootstrap + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/novox/mesh-host/internal/reachable" + "github.com/novox/mesh-host/internal/store" +) + +// quietUDP are processes whose UDP sockets every fresh machine has — name resolution, address +// configuration, time — and which serve nobody. ss names a process by its first fifteen characters, +// so both spellings are here. +// +// **Still to be measured** (novox/hq ADR 0100): this list is what a fresh machine is expected to +// hold, and it must be checked against a freshly installed lab machine before it is trusted. +var quietUDP = map[string]bool{ + "systemd-resolved": true, "systemd-resolve": true, + "systemd-networkd": true, "systemd-network": true, + "systemd-timesyncd": true, "systemd-timesyn": true, + "dhcpcd": true, +} + +// InUse says what makes this machine a machine in use (novox/hq ADR 0100): every running container +// no host made, and every socket listening on an address other than loopback that is not ssh's — a +// UDP one only when it is held by something other than what every fresh machine runs. ours names +// what the mesh itself runs, which a re-run of genesis finds and does not count. +func InUse(ctx context.Context, run Runner, ours func(name string) bool) ([]string, []reachable.Reach, error) { + var containers []string + out, err := run(ctx, "docker", "ps", "--format", "{{.Names}}\t{{.Label \"mesh-host.spec\"}}") + if err != nil { + return nil, nil, fmt.Errorf("cannot ask the container runtime what is running here: %w", err) + } + for _, line := range strings.Split(out, "\n") { + name, label, _ := strings.Cut(strings.TrimSpace(line), "\t") + label = strings.TrimSpace(label) + if name == "" || (label != "" && label != "") || ours(name) { + continue + } + containers = append(containers, name) + } + + listening, err := run(ctx, "ss", "-Hltunp") + if err != nil { + return nil, nil, fmt.Errorf("cannot read what listens on this machine: %w", err) + } + var listeners []reachable.Reach + for _, r := range reachable.Sockets(listening) { + if counts(r) && !ours(r.By) { + listeners = append(listeners, r) + } + } + return containers, listeners, nil +} + +func counts(r reachable.Reach) bool { + if ip := net.ParseIP(r.Address); ip != nil && ip.IsLoopback() { + return false + } + switch r.Protocol { + case "tcp": + return r.By != "sshd" && !(r.By == "" && r.Port == 22) + case "udp": + return !quietUDP[r.By] + } + return false +} + +// RefuseAMachineInUse is the check a converged genesis makes before changing anything: a machine +// in use is refused, naming every container and listener counted, because raising the foundation's +// filter there would close what it serves — a forgotten --adopted must not close a working machine. +// An adopted genesis is told what it found, and goes on. +func RefuseAMachineInUse(ctx context.Context, o Options, run Runner, say func(string)) error { + known, err := store.Load(o.State) + if err != nil { + return err + } + if len(known.Resources) > 0 { + // What genesis raised on an earlier run is the mesh's, and it is what the machine now + // serves; the question was answered the first time. + say(" in use not asked: this machine carries what an earlier genesis raised") + return nil + } + containers, listeners, err := InUse(ctx, run, func(string) bool { return false }) + if err != nil { + return err + } + if len(containers) == 0 && len(listeners) == 0 { + say(" in use no: no container runs and nothing listens beyond ssh") + return nil + } + var named []string + for _, c := range containers { + named = append(named, "container "+c) + } + for _, l := range listeners { + by := l.By + if by == "" { + by = "an unnamed process" + } + named = append(named, fmt.Sprintf("%s %s:%d by %s", l.Protocol, l.Address, l.Port, by)) + } + if o.Adopted { + say(fmt.Sprintf(" in use yes, and adopted: %d thing(s) found are kept", len(named))) + return nil + } + return fmt.Errorf("this machine is in use, and a converged genesis would close what it serves:\n - %s\n"+ + "If it is meant to join the mesh keeping what it runs, pass --adopted: its firewall stays in "+ + "force and every module is taken on it one at a time. Nothing was changed", + strings.Join(named, "\n - ")) +} diff --git a/internal/bootstrap/inuse_test.go b/internal/bootstrap/inuse_test.go new file mode 100644 index 0000000..b1a1e49 --- /dev/null +++ b/internal/bootstrap/inuse_test.go @@ -0,0 +1,99 @@ +package bootstrap + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0100: a converged genesis refuses a machine in use, naming every container +// and listener it counted. + +// Lines as `ss -Hltunp` prints them. The ssh, samba, loopback and proxy lines are captured from a +// real machine; the resolver, DHCP and time lines are written in the same shape for the processes a +// fresh machine runs, and still need measuring against one. +const inUseSockets = `tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1188536,fd=6)) +tcp LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=1188536,fd=7)) +tcp LISTEN 0 32 127.0.0.1:53 0.0.0.0:* users:(("dnsmasq",pid=1189392,fd=7)) +tcp LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1854543,fd=7)) +udp UNCONN 0 0 0.0.0.0:5355 0.0.0.0:* users:(("systemd-resolve",pid=301,fd=11)) +udp UNCONN 0 0 192.0.2.10%eth0:68 0.0.0.0:* users:(("systemd-network",pid=280,fd=19)) +udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhcpcd",pid=270,fd=9)) +udp UNCONN 0 0 0.0.0.0:123 0.0.0.0:* users:(("systemd-timesyn",pid=260,fd=9)) +` + +const servingSockets = `tcp LISTEN 0 50 0.0.0.0:445 0.0.0.0:* users:(("smbd",pid=1248,fd=29)) +tcp LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=1920035,fd=7)) +udp UNCONN 0 0 0.0.0.0:123 0.0.0.0:* users:(("ntpd",pid=1070791,fd=17)) +` + +type inUseRunner struct{ ps, ss string } + +func (m inUseRunner) run(_ context.Context, name string, args ...string) (string, error) { + if name == "docker" { + return m.ps, nil + } + return m.ss, nil +} + +func TestAFreshMachineIsNotInUse(t *testing.T) { + containers, listeners, err := InUse(context.Background(), inUseRunner{ss: inUseSockets}.run, + func(string) bool { return false }) + if err != nil { + t.Fatal(err) + } + if len(containers) != 0 || len(listeners) != 0 { + t.Errorf("ssh, loopback, name resolution, DHCP and time were counted: %v %v", containers, listeners) + } +} + +func TestAMachineServingIsInUse(t *testing.T) { + m := inUseRunner{ps: "hello-web\t\nmesh-store\tabc123\n", ss: inUseSockets + servingSockets} + containers, listeners, err := InUse(context.Background(), m.run, func(string) bool { return false }) + if err != nil { + t.Fatal(err) + } + if len(containers) != 1 || containers[0] != "hello-web" { + t.Errorf("containers counted: %v (one a host made is not a predecessor's)", containers) + } + var by []string + for _, l := range listeners { + by = append(by, l.By) + } + if strings.Join(by, " ") != "smbd docker-proxy ntpd" { + t.Errorf("listeners counted: %v", listeners) + } +} + +func TestAConvergedGenesisRefusesAMachineInUseNamingEverything(t *testing.T) { + o := Options{State: filepath.Join(t.TempDir(), "state.json")} + m := inUseRunner{ps: "hello-web\t\n", ss: inUseSockets + servingSockets} + err := RefuseAMachineInUse(context.Background(), o, m.run, quietly) + if err == nil { + t.Fatal("a machine in use was not refused") + } + for _, want := range []string{"container hello-web", "tcp 0.0.0.0:445 by smbd", "tcp 0.0.0.0:8080 by docker-proxy", + "udp 0.0.0.0:123 by ntpd", "--adopted"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } + o.Adopted = true + if err := RefuseAMachineInUse(context.Background(), o, m.run, quietly); err != nil { + t.Errorf("an adopted genesis was refused a machine in use: %v", err) + } +} + +func TestARerunOfGenesisIsNotAMachineInUse(t *testing.T) { + o := Options{State: filepath.Join(t.TempDir(), "state.json")} + if err := store.Save(o.State, store.State{Resources: []store.Applied{{ID: "store", Type: "container", Target: "mesh-store"}}}); err != nil { + t.Fatal(err) + } + m := inUseRunner{ps: "mesh-gitea-server\t\n", ss: servingSockets} + if err := RefuseAMachineInUse(context.Background(), o, m.run, quietly); err != nil { + t.Errorf("what an earlier genesis raised was counted as a machine in use: %v", err) + } +} diff --git a/internal/bootstrap/preflight.go b/internal/bootstrap/preflight.go index 5275aeb..7b8d145 100644 --- a/internal/bootstrap/preflight.go +++ b/internal/bootstrap/preflight.go @@ -105,6 +105,11 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte if err := waitForRuntime(ctx, d.Run, o.Timeout, o.Wait, say); err != nil { return nil, err } + // A converged genesis refuses a machine in use (novox/hq ADR 0100) — asked once the runtime + // answers, so what it runs can be counted, and before anything changes. + if err := RefuseAMachineInUse(ctx, o, d.Run, say); err != nil { + return nil, err + } // 4. Can this machine reach what the bundle's images come from? //