From cb7b71dcc56808dc382c20295356c06870a98c3c Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 11:20:21 +0200 Subject: [PATCH] Report the networks the outward links are on, for the local network (issue 154, ADR 0340) A port reached from local-network is admitted from the private networks the machine is attached to, and only the machine can say which those are. Every network on a link facing outside is said among its facts, and a change is said at the next reconcile. --- cmd/mesh-host/local_network_test.go | 29 +++++++++ cmd/mesh-host/main.go | 26 ++++++++ internal/outward/networks.go | 92 +++++++++++++++++++++++++++++ internal/outward/networks_test.go | 59 ++++++++++++++++++ internal/profile/machine.go | 37 +++++++++++- internal/profile/machine_test.go | 29 +++++++++ 6 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 cmd/mesh-host/local_network_test.go create mode 100644 internal/outward/networks.go create mode 100644 internal/outward/networks_test.go diff --git a/cmd/mesh-host/local_network_test.go b/cmd/mesh-host/local_network_test.go new file mode 100644 index 0000000..3a7af99 --- /dev/null +++ b/cmd/mesh-host/local_network_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/outward" + "github.com/novox/mesh-host/internal/profile" +) + +func reportOn(networks ...outward.Network) link.Report { + return link.Report{Outward: []string{"eth0"}, + Profile: profileAsReported(profile.Profile{Machine: &profile.Machine{Networks: networks}})} +} + +// **A change of the networks a machine is attached to is said unasked** (novox/hq ADR 0340 §7): a port +// reached from the local network is admitted from them, so a laptop that joins another network is said at +// the next reconcile, not at the next push — and the same networks again are not news. +func TestAChangedNetworkIsSaidUnasked(t *testing.T) { + w := &adoptionWatch{} + home := reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"}) + w.said(home) + if w.differs(reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})) { + t.Fatal("the same networks read as a change, so the machine would speak on every reconcile") + } + if !w.differs(reportOn(outward.Network{Link: "eth0", Network: "10.20.0.0/16"})) { + t.Fatal("a machine that moved to another network does not say so until it is pushed to") + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 83b5473..1758a11 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1397,6 +1397,12 @@ func adoptionFingerprint(r link.Report) string { // it could only speak when a declaration arrived, and a declaration cannot be composed until it // has spoken: a machine waiting for a push that is waiting for the machine. parts := []string{"firewall=" + r.Firewall, "outward=" + strings.Join(r.Outward, ",")} + // **And the networks those links are on** (novox/hq ADR 0340 §7): a port reached from the local + // network is admitted from them, so a laptop that joins another network, or a lease that moves, is + // said at the next reconcile rather than at the next push or the next day. + if networks := attachedNetworksOf(r); networks != "" { + parts = append(parts, "networks="+networks) + } for _, h := range r.Held { parts = append(parts, "held "+h.ID+"="+h.Changed) } @@ -1428,6 +1434,26 @@ func adoptionFingerprint(r link.Report) string { return strings.Join(parts, "\n") } +// attachedNetworksOf is the networks a report's profile says the machine is attached to, as one line: the +// part of what the machine is made of that the filter is composed from (novox/hq ADR 0340). +func attachedNetworksOf(r link.Report) string { + machine, ok := r.Profile["machine"].(map[string]any) + if !ok { + return "" + } + networks, ok := machine["networks"].([]any) + if !ok { + return "" + } + said := make([]string, 0, len(networks)) + for _, n := range networks { + if entry, ok := n.(map[string]any); ok { + said = append(said, fmt.Sprintf("%v %v", entry["link"], entry["network"])) + } + } + return strings.Join(said, ",") +} + // applyState is how an apply went, as the fingerprint keeps it: refused, clean, or failed with what it did not // apply and which keys its failure is said under. func applyState(r link.Report) string { diff --git a/internal/outward/networks.go b/internal/outward/networks.go new file mode 100644 index 0000000..8075726 --- /dev/null +++ b/internal/outward/networks.go @@ -0,0 +1,92 @@ +package outward + +import ( + "errors" + "fmt" + "net" + "net/netip" + "sort" +) + +// Network is one network this machine is directly attached to, and the link it is on (novox/hq ADR +// 0340 §7): the address and prefix of the network, not of the machine's own address on it. +// +// **Every one is said, and the machine judges none of them.** Which count as the machine's local +// network — private ranges only, never a public subnet, never a link-local one — is the controller's +// judgement (ADR 0340 §2), made where the filter is composed and shown with why; a machine that left a +// network out would make that judgement silently. +type Network struct { + Link string `json:"link"` + Network string `json:"network"` +} + +// AddressesOf is the addresses on one link, as address and prefix. A parameter so a test can hold a +// machine's links without one. +type AddressesOf func(link string) ([]netip.Prefix, error) + +// RealAddresses reads one link's addresses from the kernel. +func RealAddresses(link string) ([]netip.Prefix, error) { + iface, err := net.InterfaceByName(link) + if err != nil { + return nil, err + } + addrs, err := iface.Addrs() + if err != nil { + return nil, err + } + var out []netip.Prefix + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok { + continue + } + addr, ok := netip.AddrFromSlice(ipnet.IP) + if !ok { + continue + } + bits, _ := ipnet.Mask.Size() + out = append(out, netip.PrefixFrom(addr.Unmap(), bits)) + } + return out, nil +} + +// Networks is the networks on each of these links — the links that face outside (Links) — sorted by link +// and network, without repeats. A link that has gone since Links read it has no networks rather than +// failing the rest; a link whose addresses cannot be read fails, so the machine says the fact is unknown +// rather than reporting a partial account as the whole. +func Networks(links []string, addressesOf AddressesOf) ([]Network, error) { + if addressesOf == nil { + addressesOf = RealAddresses + } + seen := map[Network]bool{} + var out []Network + for _, link := range links { + prefixes, err := addressesOf(link) + if err != nil { + if isNoSuchInterface(err) { + continue + } + return nil, fmt.Errorf("cannot read the addresses on %s: %w", link, err) + } + for _, p := range prefixes { + n := Network{Link: link, Network: p.Masked().String()} + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Link != out[j].Link { + return out[i].Link < out[j].Link + } + return out[i].Network < out[j].Network + }) + return out, nil +} + +// isNoSuchInterface is the error net.InterfaceByName gives for a link that is not there. +func isNoSuchInterface(err error) bool { + var op *net.OpError + return errors.As(err, &op) && op.Err != nil && op.Err.Error() == "no such network interface" +} diff --git a/internal/outward/networks_test.go b/internal/outward/networks_test.go new file mode 100644 index 0000000..182d3a3 --- /dev/null +++ b/internal/outward/networks_test.go @@ -0,0 +1,59 @@ +package outward + +import ( + "errors" + "net/netip" + "reflect" + "testing" +) + +func prefixes(s ...string) []netip.Prefix { + var out []netip.Prefix + for _, p := range s { + out = append(out, netip.MustParsePrefix(p)) + } + return out +} + +// **Each link facing outside says the networks it is attached to** (novox/hq ADR 0340 §7), as the +// network's address and prefix, never the machine's own address on it — and every one, public and +// link-local included, because which count is the controller's judgement and said with why. +func TestEachOutwardLinkSaysTheNetworksItIsOn(t *testing.T) { + addrs := map[string][]netip.Prefix{ + "eth0": prefixes("192.168.1.10/24", "fe80::1/64", "fd12:3456:789a::5/64"), + "eth1": prefixes("203.0.113.7/24"), + // Not asked: the bridge is not a link facing outside. + "docker0": prefixes("172.17.0.1/16"), + } + got, err := Networks([]string{"eth1", "eth0"}, func(link string) ([]netip.Prefix, error) { return addrs[link], nil }) + if err != nil { + t.Fatal(err) + } + want := []Network{ + {Link: "eth0", Network: "192.168.1.0/24"}, + {Link: "eth0", Network: "fd12:3456:789a::/64"}, + {Link: "eth0", Network: "fe80::/64"}, + {Link: "eth1", Network: "203.0.113.0/24"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v\nwant %+v", got, want) + } +} + +// A link whose addresses cannot be read fails the whole, so the machine says the fact is unknown rather +// than reporting part of its networks as all of them. +func TestAnUnreadableLinkIsNotAPartialAccount(t *testing.T) { + _, err := Networks([]string{"eth0"}, func(string) ([]netip.Prefix, error) { return nil, errors.New("denied") }) + if err == nil { + t.Fatal("an unreadable link was reported as having no networks") + } +} + +// A link gone between reading the routing table and reading its addresses has no networks, and the +// rest are still said. +func TestALinkGoneSinceItWasListedHasNoNetworks(t *testing.T) { + got, err := Networks([]string{"gone0"}, RealAddresses) + if err != nil || len(got) != 0 { + t.Fatalf("got %v, %v", got, err) + } +} diff --git a/internal/profile/machine.go b/internal/profile/machine.go index f67a168..3e9a807 100644 --- a/internal/profile/machine.go +++ b/internal/profile/machine.go @@ -10,6 +10,8 @@ import ( "sync" "syscall" "time" + + "github.com/novox/mesh-host/internal/outward" ) // Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody @@ -50,6 +52,11 @@ type Machine struct { // Filesystems is each local filesystem mounted, by where, with its size and what is free. Filesystems []Filesystem `json:"filesystems,omitempty"` + // Networks is every network the machine is directly attached to on a link facing outside it, as the + // link and the network's address and prefix (novox/hq ADR 0340 §7): what a port reached from + // `local-network` is resolved from. Every one is said; which count is the controller's judgement. + Networks []outward.Network `json:"networks,omitempty"` + // Unknown names each fact that could not be determined, and why. Unknown map[string]string `json:"unknown,omitempty"` } @@ -79,11 +86,12 @@ const ( FactTimezone = "timezone" FactFilesystems = "filesystems" FactPublicAddress = "public address" + FactNetworks = "networks" ) // MachineFacts is every fact Unknown may name, in the order a reader shows them. var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores, - FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress} + FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactNetworks, FactPublicAddress} // publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see // its public address from itself, and asking a service outside the mesh is a dependency the mesh does not @@ -101,11 +109,24 @@ type Sources struct { // Statfs is (size, free) in bytes of the filesystem mounted at a path. Statfs func(path string) (size, free int64, err error) Now func() time.Time + // Networks is the networks on the links facing outside this machine (novox/hq ADR 0340). + Networks func() ([]outward.Network, error) } // RealSources reads this machine. func RealSources() Sources { - return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now} + return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now, + Networks: attachedNetworks} +} + +// attachedNetworks is this machine's networks on the links that face outside it, read the way the report +// reads those links (novox/hq ADR 0140), so the two never disagree about which links those are. +func attachedNetworks() ([]outward.Network, error) { + links, err := outward.Links("", "") + if err != nil { + return nil, err + } + return outward.Networks(links, nil) } func statfs(path string) (int64, int64, error) { @@ -193,6 +214,18 @@ func DescribeMachine(ctx context.Context, src Sources) Machine { } } + // The networks it is attached to, for the local network (novox/hq ADR 0340): every one on a link facing + // outside, or why none is said — never an empty list that reads as a machine on no network. + if src.Networks == nil { + unknown(FactNetworks, "this node-engine was given no way to read its links") + } else if networks, err := src.Networks(); err != nil { + unknown(FactNetworks, err.Error()) + } else if len(networks) == 0 { + unknown(FactNetworks, "no link facing outside this machine carries an address") + } else { + m.Networks = networks + } + unknown(FactPublicAddress, publicAddressUnknown) return m } diff --git a/internal/profile/machine_test.go b/internal/profile/machine_test.go index 9cf9e17..64c7093 100644 --- a/internal/profile/machine_test.go +++ b/internal/profile/machine_test.go @@ -10,6 +10,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/novox/mesh-host/internal/outward" ) // The parsing layer, with the machine's files given (novox/hq ADR 0017 allows a fake here and only here); @@ -40,6 +42,9 @@ func given(read map[string]string) Sources { Run: func(context.Context, string, ...string) (string, error) { return "none\n", errors.New("exited 1") }, Statfs: func(p string) (int64, int64, error) { return 1000, 400, nil }, Now: func() time.Time { return time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) }, + Networks: func() ([]outward.Network, error) { + return []outward.Network{{Link: "eth0", Network: "192.168.1.0/24"}, {Link: "eth1", Network: "203.0.113.0/24"}}, nil + }, } } @@ -92,6 +97,7 @@ func TestMachine_aFactItCannotReadIsSaidUnknownNeverZero(t *testing.T) { src := given(map[string]string{}) src.Readlink = func(string) (string, error) { return "", os.ErrNotExist } src.Run = func(context.Context, string, ...string) (string, error) { return "", errors.New("not found") } + src.Networks = func() ([]outward.Network, error) { return nil, errors.New("cannot read the routing table") } m := DescribeMachine(context.Background(), src) raw, _ := json.Marshal(m) @@ -183,3 +189,26 @@ func TestAgainstThisMachine_machineSaysWhatItIs(t *testing.T) { raw, _ := json.MarshalIndent(m, "", " ") t.Logf("%s", raw) } + +// **The networks it is attached to are among its facts** (novox/hq ADR 0340 §7), every one — the public +// one too, which the controller judges and never counts — and a machine with none on a link facing +// outside says why rather than reporting an empty list. +func TestMachine_saysTheNetworksItIsAttachedTo(t *testing.T) { + m := DescribeMachine(context.Background(), given(files)) + raw, _ := json.Marshal(m) + var said struct { + Networks []map[string]string `json:"networks"` + } + _ = json.Unmarshal(raw, &said) + if len(said.Networks) != 2 || said.Networks[0]["link"] != "eth0" || said.Networks[0]["network"] != "192.168.1.0/24" || + said.Networks[1]["network"] != "203.0.113.0/24" { + t.Fatalf("networks said as %s", raw) + } + + src := given(files) + src.Networks = func() ([]outward.Network, error) { return nil, nil } + m = DescribeMachine(context.Background(), src) + if len(m.Networks) != 0 || !strings.Contains(m.Unknown[FactNetworks], "no link facing outside") { + t.Fatalf("a machine with no network said %v, unknown %q", m.Networks, m.Unknown[FactNetworks]) + } +}