Report what an adopted node holds, its firewall and what is reachable, and speak unasked when that changes (hq ADR 0100)

This commit is contained in:
2026-09-22 17:22:31 +02:00
parent 3c90d155b3
commit 770f589401
11 changed files with 528 additions and 8 deletions
+181
View File
@@ -0,0 +1,181 @@
// Package reachable reads what can be reached on this machine now: every listening socket, and
// every container port the runtime publishes (novox/hq ADR 0100).
//
// It is what converging an adopted node previews — each port, whether a module declares it or it
// will close — and what a converged genesis counts before refusing a machine in use. It reads; it
// never decides what is the mesh's.
package reachable
import (
"context"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/system"
)
// Runner executes a command.
type Runner = system.Runner
// Reach is one thing reachable on this machine, in the words the report carries.
type Reach = link.Reach
// Collect reads the machine's listening sockets and the runtime's published ports. A published
// port is reported once, as published, rather than again as the runtime's proxy listening for it.
func Collect(ctx context.Context, run Runner) ([]Reach, error) {
out, err := run(ctx, "ss", "-Hltunp")
if err != nil {
return nil, fmt.Errorf("reading this machine's listening sockets: %w", err)
}
sockets := Sockets(out)
var published []Reach
if ps, err := run(ctx, "docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"); err == nil {
published = Published(ps)
}
return Merge(sockets, published), nil
}
var process = regexp.MustCompile(`users:\(\("([^"]+)"`)
// Sockets parses `ss -Hltunp`: each line a netid, a state, two queues, the local address and
// port, the peer, and the process when ss may name it.
func Sockets(out string) []Reach {
var reached []Reach
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
protocol := fields[0]
if protocol != "tcp" && protocol != "udp" {
continue
}
address, port, ok := splitLocal(fields[4])
if !ok {
continue
}
r := Reach{Protocol: protocol, Address: address, Port: port}
if m := process.FindStringSubmatch(line); m != nil {
r.By = m[1]
}
reached = append(reached, r)
}
return reached
}
// splitLocal reads "127.0.0.1:53", "[::]:22", "*:22" and "[fe80::1]%veth0:123".
func splitLocal(local string) (string, int, bool) {
i := strings.LastIndex(local, ":")
if i < 0 {
return "", 0, false
}
port, err := strconv.Atoi(local[i+1:])
if err != nil {
return "", 0, false
}
address := local[:i]
if at := strings.Index(address, "%"); at >= 0 {
address = address[:at]
}
address = strings.TrimSuffix(strings.TrimPrefix(address, "["), "]")
if address == "*" {
address = "0.0.0.0"
}
return address, port, true
}
// Published parses `docker ps --format '{{.Names}}\t{{.Ports}}'`. Only what is published on the
// machine counts; a port a container exposes and nothing publishes is not reachable from outside it.
func Published(out string) []Reach {
var reached []Reach
for _, line := range strings.Split(out, "\n") {
name, ports, ok := strings.Cut(strings.TrimSpace(line), "\t")
if !ok {
continue
}
for _, mapping := range strings.Split(ports, ",") {
reached = append(reached, mappingOf(name, strings.TrimSpace(mapping))...)
}
}
return reached
}
// mappingOf reads "0.0.0.0:9000-9001->9000-9001/tcp" into one reach per port.
func mappingOf(name, mapping string) []Reach {
outer, inner, ok := strings.Cut(mapping, "->")
if !ok {
return nil
}
inner, protocol, ok := strings.Cut(inner, "/")
if !ok {
return nil
}
i := strings.LastIndex(outer, ":")
if i < 0 {
return nil
}
address := strings.TrimSuffix(strings.TrimPrefix(outer[:i], "["), "]")
from, to, ok := portRange(outer[i+1:])
if !ok {
return nil
}
cfrom, _, ok := portRange(inner)
if !ok {
return nil
}
var reached []Reach
for p := from; p <= to; p++ {
reached = append(reached, Reach{Protocol: protocol, Address: address, Port: p, By: name,
Published: true, ContainerPort: cfrom + (p - from)})
}
return reached
}
func portRange(s string) (int, int, bool) {
a, b, isRange := strings.Cut(s, "-")
from, err := strconv.Atoi(a)
if err != nil {
return 0, 0, false
}
if !isRange {
return from, from, true
}
to, err := strconv.Atoi(b)
if err != nil || to < from {
return 0, 0, false
}
return from, to, true
}
// Merge puts the published ports beside the sockets, dropping the runtime proxy's own socket for a
// port that is reported as published already, and sorts the whole by port.
func Merge(sockets, published []Reach) []Reach {
key := func(r Reach) string { return r.Protocol + " " + r.Address + " " + strconv.Itoa(r.Port) }
isPublished := map[string]bool{}
for _, p := range published {
isPublished[key(p)] = true
}
var out []Reach
for _, s := range sockets {
if s.By == "docker-proxy" && isPublished[key(s)] {
continue
}
out = append(out, s)
}
out = append(out, published...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].Port != out[j].Port {
return out[i].Port < out[j].Port
}
if out[i].Protocol != out[j].Protocol {
return out[i].Protocol < out[j].Protocol
}
return out[i].Address < out[j].Address
})
return out
}
+100
View File
@@ -0,0 +1,100 @@
package reachable
import (
"context"
"os"
"strings"
"testing"
)
// Defends novox/hq ADR 0100: converging previews every listening socket and every published
// container port. Fixtures are captured from a real machine.
func fixture(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func find(rs []Reach, protocol, address string, port int) (Reach, bool) {
for _, r := range rs {
if r.Protocol == protocol && r.Address == address && r.Port == port {
return r, true
}
}
return Reach{}, false
}
func TestSocketsAreReadWithWhatHoldsThem(t *testing.T) {
got := Sockets(fixture(t, "ss.txt"))
if r, ok := find(got, "tcp", "0.0.0.0", 22); !ok || r.By != "sshd" {
t.Errorf("ssh not read: %+v", r)
}
if r, ok := find(got, "tcp", "::", 445); !ok || r.By != "smbd" {
t.Errorf("an IPv6 wildcard listener not read: %+v", r)
}
if _, ok := find(got, "udp", "fe80::849e:ccff:fea8:24c7", 123); !ok {
t.Error("a link-local address with a scope was not read")
}
if r, ok := find(got, "udp", "127.0.0.1", 53); !ok || r.By != "dnsmasq" {
t.Errorf("a loopback udp socket not read: %+v", r)
}
}
func TestPublishedPortsNameTheirContainerAndItsPort(t *testing.T) {
got := Published(fixture(t, "docker-ps.txt"))
if r, ok := find(got, "tcp", "0.0.0.0", 8770); !ok || r.By != "whisper" || r.ContainerPort != 8000 || !r.Published {
t.Errorf("a published port: %+v", r)
}
if r, ok := find(got, "tcp", "0.0.0.0", 9001); !ok || r.ContainerPort != 9001 {
t.Errorf("a published range was not expanded: %+v", r)
}
if r, ok := find(got, "tcp", "127.0.0.1", 15673); !ok || r.ContainerPort != 15672 {
t.Errorf("a loopback-published port: %+v", r)
}
for _, r := range got {
if r.By == "umami_db" {
t.Errorf("an exposed and unpublished port was reported reachable: %+v", r)
}
}
}
func TestAPublishedPortIsReportedOnceAsPublished(t *testing.T) {
merged := Merge(Sockets(fixture(t, "ss.txt")), Published(fixture(t, "docker-ps.txt")))
n := 0
for _, r := range merged {
if r.Protocol == "tcp" && r.Address == "0.0.0.0" && r.Port == 8770 {
n++
if !r.Published {
t.Errorf("the runtime's proxy was reported instead of the published port: %+v", r)
}
}
}
if n != 1 {
t.Errorf("port 8770 reported %d times", n)
}
if _, ok := find(merged, "tcp", "0.0.0.0", 22); !ok {
t.Error("a socket was lost in the merge")
}
}
func TestCollectAsksSsAndTheRuntime(t *testing.T) {
var asked []string
run := func(_ context.Context, name string, args ...string) (string, error) {
asked = append(asked, name+" "+strings.Join(args, " "))
if name == "ss" {
return fixture(t, "ss.txt"), nil
}
return fixture(t, "docker-ps.txt"), nil
}
got, err := Collect(context.Background(), run)
if err != nil || len(got) == 0 {
t.Fatalf("%v %v", got, err)
}
if len(asked) != 2 {
t.Errorf("asked %v", asked)
}
}
+7
View File
@@ -0,0 +1,7 @@
mesh-controller-check-adoption 127.0.0.1:55541->5432/tcp
umami_db 5432/tcp
whisper 0.0.0.0:8770->8000/tcp, [::]:8770->8000/tcp
keycloak 8443/tcp, 127.0.0.1:28080->8080/tcp
minio-lb 0.0.0.0:9000-9001->9000-9001/tcp, [::]:9000-9001->9000-9001/tcp
wonderful_mahavira
anton-lavinmq 0.0.0.0:5680->5672/tcp, [::]:5680->5672/tcp, 127.0.0.1:15673->15672/tcp
+23
View File
@@ -0,0 +1,23 @@
udp UNCONN 0 0 0.0.0.0:55558 0.0.0.0:* users:(("firefox",pid=2283907,fd=288))
udp UNCONN 0 0 0.0.0.0:59541 0.0.0.0:* users:(("firefox",pid=2283907,fd=241))
udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("dnsmasq",pid=1189392,fd=6))
udp UNCONN 0 0 0.0.0.0:33525 0.0.0.0:* users:(("firefox",pid=2283907,fd=304))
udp UNCONN 0 0 0.0.0.0:41749 0.0.0.0:* users:(("firefox",pid=2283907,fd=351))
tcp LISTEN 0 4096 127.0.0.1:55541 0.0.0.0:* users:(("docker-proxy",pid=4108732,fd=7))
tcp LISTEN 0 4096 0.0.0.0:9001 0.0.0.0:* users:(("docker-proxy",pid=1849130,fd=7))
tcp LISTEN 0 4096 0.0.0.0:8770 0.0.0.0:* users:(("docker-proxy",pid=1920035,fd=7))
tcp LISTEN 0 50 0.0.0.0:445 0.0.0.0:* users:(("smbd",pid=1248,fd=29))
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1188536,fd=6))
tcp LISTEN 0 50 0.0.0.0:139 0.0.0.0:* users:(("smbd",pid=1248,fd=30))
tcp LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1854543,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:15673 0.0.0.0:* users:(("docker-proxy",pid=3170,fd=7))
tcp LISTEN 0 4096 [::]:9001 [::]:* users:(("docker-proxy",pid=1849138,fd=7))
tcp LISTEN 0 4096 [::]:8770 [::]:* users:(("docker-proxy",pid=1920043,fd=7))
tcp LISTEN 0 50 [::]:445 [::]:* users:(("smbd",pid=1248,fd=27))
tcp LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=1188536,fd=7))
tcp LISTEN 0 50 [::]:139 [::]:* users:(("smbd",pid=1248,fd=28))
udp UNCONN 0 0 [fd42:f8c5:dae:d74c::1]:53 [::]:*
udp UNCONN 0 0 [fe80::849e:ccff:fea8:24c7]%veth6b2b7ba:123 [::]:*
udp UNCONN 0 0 [fe80::e45a:90ff:feca:148f]%vethb5e5a61:123 [::]:*
udp UNCONN 0 0 [fe80::c4ed:ccff:feb1:afd2]%veth005a182:123 [::]:*