From b0d11c24399a878e0816fdbd895c887ec65c2f99 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 28 Sep 2026 23:37:06 +0200 Subject: [PATCH] A machine says which of its links face outside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter blocks everything passing through the machine and then allows the machine's own containers back by naming the address ranges they sit on — two ranges fixed in the control plane and the rest typed after a flip had already cut a workstation off. A range describes one machine and goes stale in silence. Read the links carrying a default route instead, from /proc rather than by asking a program, and report them on every apply. A machine with no route off itself reports nothing, and the mesh composes no filter for it rather than writing a rule around a link with no name. novox/hq ADR 0140. The control plane does not read this yet. --- cmd/mesh-host/main.go | 10 +++ internal/link/messages.go | 14 ++++ internal/outward/links.go | 147 +++++++++++++++++++++++++++++++++ internal/outward/links_test.go | 120 +++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 internal/outward/links.go create mode 100644 internal/outward/links_test.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 5b6ae72..7c56afc 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -33,6 +33,7 @@ import ( "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/inventory" "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/outward" "github.com/novox/mesh-host/internal/profile" "github.com/novox/mesh-host/internal/reachable" "github.com/novox/mesh-host/internal/store" @@ -1263,6 +1264,15 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D } report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw)} + // Which of this machine's links face outside, for the filter the mesh writes around them + // (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it, + // and an adopted one becomes converged without a further round trip. A machine that cannot read + // its own routing table says nothing rather than guessing, and is sent no filter. + if links, err := outward.Links(""); err != nil { + fmt.Fprintf(os.Stderr, "mesh-host: applied, and could not read which links face outside: %v\n", err) + } else { + report.Outward = links + } // What this node found and holds, its firewall, and what is reachable on it — so an adopted // node never reads as converged (novox/hq ADR 0100). for _, h := range updated.Held { diff --git a/internal/link/messages.go b/internal/link/messages.go index 76765a0..f560fa4 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -110,6 +110,20 @@ type Report struct { // and the mesh's up in its place, and where the found configuration's original was kept. Tunnel *CarriedTunnel `json:"tunnel,omitempty"` + // Outward is the links on this machine that face outside it — the ones carrying a default + // route (novox/hq ADR 0140). Every node reports it, adopted or converged, because a converged + // node's filter is written around it. + // + // **It replaces a list of addresses.** The filter used to block everything passing through the + // machine and then allow the machine's own containers back by naming the ranges they sit on. + // A range describes one machine and goes stale in silence; the link carrying the default route + // is read afresh on every report and does not change when a module is added or removed. + // + // Empty means this machine has no route off itself. The mesh then composes no filter for it and + // leaves the one it has, rather than writing a rule around a link with no name — a rule set + // that does not load is a machine filtering nothing while its unit reports success. + Outward []string `json:"outward,omitempty"` + // Rekey is this node taking a found tunnel's key as its overlay key after enrolment (novox/hq // ADR 0105). Not an account of the machine: a report carrying one says nothing else. Rekey *Rekey `json:"rekey,omitempty"` diff --git a/internal/outward/links.go b/internal/outward/links.go new file mode 100644 index 0000000..f15edd7 --- /dev/null +++ b/internal/outward/links.go @@ -0,0 +1,147 @@ +// Package outward reads which of this machine's links face outside it (novox/hq ADR 0140). +// +// The filter the mesh derives constrains traffic arriving from outside the machine and says nothing +// about traffic that did not. To write that rule the mesh has to know which links "outside" arrives +// on, and that is a thing only the machine can say — so it says it, once per report, the way it +// already reports the kind of firewall it found and the tunnel it carried. +// +// **It replaces a list of addresses.** The filter used to allow the machine's own containers back +// through by naming the address ranges they sit on: two ranges fixed in the control plane's source +// and the rest typed by an operator. A range describes one machine and goes stale silently +// (novox/hq 04-ISSUES/137 and /141). A link that carries the default route is a fact the machine +// reads afresh every time, and it does not change when a module is added or removed. +// +// It reads the kernel's routing tables directly rather than asking a program. A module naming a +// program the machine does not have is how the mesh already reported success while doing nothing +// (novox/hq 04-ISSUES/136), and every machine has /proc. +package outward + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// ProcNet is where the kernel publishes its routing tables. A parameter so a test can hold a +// routing table without one. +const ProcNet = "/proc/net" + +// Links are the interfaces carrying a default route, for both address families, sorted and without +// repeats. +// +// A machine may have more than one: a laptop with a cable and a radio has two, and both face +// outside. A machine with none — no route off itself — returns nothing, and the mesh refuses to +// compose a filter for it rather than writing a rule around a link with no name, which would be a +// rule set that does not load and a machine filtering nothing while its unit reports success. +func Links(procNet string) ([]string, error) { + if procNet == "" { + procNet = ProcNet + } + seen := map[string]bool{} + + four, err := defaultsV4(filepath.Join(procNet, "route")) + if err != nil { + return nil, err + } + six, err := defaultsV6(filepath.Join(procNet, "ipv6_route")) + if err != nil { + return nil, err + } + for _, name := range append(four, six...) { + if name != "" && name != "lo" { + seen[name] = true + } + } + + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + sort.Strings(out) + return out, nil +} + +// defaultsV4 reads /proc/net/route, whose columns are +// +// Iface Destination Gateway Flags RefCnt Use Metric Mask ... +// +// with addresses in hexadecimal. A default route is destination zero with mask zero — the mask +// matters, because a route to the zero address with a real mask is not a default route. +func defaultsV4(path string) ([]string, error) { + lines, err := rows(path) + if err != nil { + return nil, err + } + var out []string + for _, fields := range lines { + if len(fields) < 8 { + continue + } + if isZeroHex(fields[1]) && isZeroHex(fields[7]) { + out = append(out, fields[0]) + } + } + return out, nil +} + +// defaultsV6 reads /proc/net/ipv6_route, whose columns are +// +// dest destprefix src srcprefix nexthop metric refcnt use flags iface +// +// A default route is the zero destination with a zero prefix length. +func defaultsV6(path string) ([]string, error) { + lines, err := rows(path) + if err != nil { + return nil, err + } + var out []string + for _, fields := range lines { + if len(fields) < 10 { + continue + } + if isZeroHex(fields[0]) && isZeroHex(fields[1]) { + out = append(out, fields[9]) + } + } + return out, nil +} + +// rows reads a routing table into fields per line, skipping a header and blank lines. A table that +// is not there is not an error: a machine without the second address family has no file for it, +// and that is not a machine that cannot be filtered. +func rows(path string) ([][]string, error) { + file, err := os.Open(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err) + } + defer file.Close() + + var out [][]string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "Iface") { + continue + } + out = append(out, strings.Fields(line)) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err) + } + return out, nil +} + +// isZeroHex is whether a hexadecimal field is all zeroes, whatever its width — the v4 table writes +// eight digits and the v6 table thirty-two, and a prefix length is two. +func isZeroHex(field string) bool { + if field == "" { + return false + } + return strings.Trim(strings.ToLower(field), "0") == "" +} diff --git a/internal/outward/links_test.go b/internal/outward/links_test.go new file mode 100644 index 0000000..f1a2e6a --- /dev/null +++ b/internal/outward/links_test.go @@ -0,0 +1,120 @@ +package outward + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// A routing table as the kernel writes it: a default route, a route to the zero address that is not +// one, and a route on the loopback. Only the default route's link faces outside. +const routeV4 = `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +enp9s0 00000000 01FEA8C0 0003 0 0 100 00000000 0 0 0 +docker0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0 +enp9s0 00000000 00000000 0001 0 0 100 00FFFFFF 0 0 0 +lo 00000000 00000000 0003 0 0 0 00000000 0 0 0 +` + +const routeV6 = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 wlan0 +fd0000000000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000000 00000000 00000001 enp9s0 +` + +func write(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestLinksAreTheOnesCarryingADefaultRoute(t *testing.T) { + dir := t.TempDir() + write(t, dir, "route", routeV4) + write(t, dir, "ipv6_route", routeV6) + + got, err := Links(dir) + if err != nil { + t.Fatal(err) + } + // The cable from the v4 table and the radio from the v6 one. Not docker0, whose route is not a + // default; not the loopback, which faces nothing; and not the v6 route with a real prefix. + want := []string{"enp9s0", "wlan0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("outward links are %v, want %v", got, want) + } +} + +// A route to the zero address with a real mask is not a default route. Trusting the destination +// alone would name every link with such a route as facing outside, and a filter that treats an +// internal bridge as outward constrains this machine's own guests — the fault ADR 0140 removes. +func TestAZeroDestinationWithAMaskIsNotADefaultRoute(t *testing.T) { + dir := t.TempDir() + write(t, dir, "route", `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +br-abc 00000000 00000000 0001 0 0 0 00FFFFFF 0 0 0 +`) + got, err := Links(dir) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("outward links are %v, want none", got) + } +} + +// A machine with no route off itself says so, rather than guessing. The mesh refuses to compose a +// filter for it; a rule written around a link with no name does not load, and a rule set that does +// not load is a machine filtering nothing while its unit reports success. +func TestNoDefaultRouteIsNoLinks(t *testing.T) { + dir := t.TempDir() + write(t, dir, "route", "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n") + got, err := Links(dir) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("outward links are %v, want none", got) + } +} + +// A machine without the second address family has no file for it. That is not a machine that cannot +// be filtered, so a missing table is read as no routes rather than as a failure. +func TestAMissingTableIsNotAFailure(t *testing.T) { + dir := t.TempDir() + write(t, dir, "route", routeV4) + got, err := Links(dir) + if err != nil { + t.Fatalf("a missing v6 table should not fail: %v", err) + } + if !reflect.DeepEqual(got, []string{"enp9s0"}) { + t.Fatalf("outward links are %v, want [enp9s0]", got) + } +} + +// The same link carrying a default route in both families is reported once. +func TestALinkIsReportedOnce(t *testing.T) { + dir := t.TempDir() + write(t, dir, "route", routeV4) + write(t, dir, "ipv6_route", + "00000000000000000000000000000000 00 00000000000000000000000000000000 00 "+ + "fe800000000000000000000000000001 00000400 00000001 00000000 00000003 enp9s0\n") + got, err := Links(dir) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, []string{"enp9s0"}) { + t.Fatalf("outward links are %v, want [enp9s0]", got) + } +} + +// Against this machine's own routing table, so the parse is held to what the kernel actually writes +// and not only to a fixture written to agree with it. +func TestAgainstThisMachinesOwnTable(t *testing.T) { + got, err := Links("") + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Skip("this machine has no default route") + } + t.Logf("this machine's outward links: %v", got) +} -- 2.54.0