diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index eb93cdee..411ae05d 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -722,7 +722,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s return "", err } derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh, - outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks} + outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks, local: with.LocalNetworks} filtering, err := inv.FilteringOf(ctx, node) if err != nil { return "", err @@ -930,6 +930,9 @@ type derivedFilter struct { // The filter constrains what arrives on them; everything arriving elsewhere is this machine's // own guest and is not filtered. outwardLinks []string + // local is the machine's local networks, which a port from `local-network` stays open to (novox/hq + // ADR 0340). + local []string } // closesOutside is what a narrowing from everywhere to the private network is called: it closes. @@ -977,6 +980,17 @@ func (d derivedFilter) fate(r inventory.Reach) string { if !onMesh { return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by) } + case catalogue.FromLocalNetwork: + // The private network and this machine's local networks (novox/hq ADR 0340): it closes to + // everything else outside, and says which local networks stay. + if !onMesh { + if len(d.local) == 0 { + return fmt.Sprintf("%s — declared by %s from the local network, and this machine "+ + "reported none", closesOutside, by) + } + return fmt.Sprintf("WILL CLOSE to everything outside the private network and %s — declared "+ + "by %s from the local network", strings.Join(d.local, ", "), by) + } } return fmt.Sprintf("declared by %s (from %s)", by, rule.From) } diff --git a/cmd/mesh-controller/local_network.go b/cmd/mesh-controller/local_network.go new file mode 100644 index 00000000..f3af5d91 --- /dev/null +++ b/cmd/mesh-controller/local_network.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/overlay" +) + +// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local +// network, from what it last said of itself and the links it said face outside it. A machine that has +// said none judges to nothing, and a port reached from `local-network` is then admitted from the private +// network only, and said so. +func localNetworksOf(ctx context.Context, inv *inventory.Inventory, node string) ([]catalogue.LocalNetwork, error) { + account, had, err := inv.MachineOf(ctx, node) + if err != nil { + return nil, err + } + outward, err := inv.OutwardLinksOf(ctx, node) + if err != nil { + return nil, err + } + return judgedNetworks(account, had, outward, overlay.Interface), nil +} + +// judgedNetworks is the judging alone, from what was read. +func judgedNetworks(account inventory.MachineAccount, had bool, outward []string, tunnel string) []catalogue.LocalNetwork { + if !had || account.Machine == nil { + return nil + } + attached := make([]catalogue.AttachedNetwork, 0, len(account.Machine.Networks)) + for _, n := range account.Machine.Networks { + attached = append(attached, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network}) + } + return catalogue.JudgeLocalNetworks(attached, outward, tunnel) +} + +// localNetworkLines is what `node` shows of a machine's attached networks (ADR 0340 §8): each one, with +// whether it counts as the local network and why, or why none is shown. +func localNetworkLines(account inventory.MachineAccount, had bool, judged []catalogue.LocalNetwork) []string { + out := []string{" its local network (what a port reached from local-network is admitted from):"} + switch { + case !had || account.Machine == nil: + return append(out, " not said — this machine has not reported what it is attached to") + case len(account.Machine.Networks) == 0: + why := account.Machine.Unknown["networks"] + if why == "" { + why = "its node-engine is older than the one that says it" + } + return append(out, " not said — "+why) + } + counted := 0 + for _, j := range judged { + verdict := "not counted" + if j.Counts { + verdict = "counts" + counted++ + } + out = append(out, fmt.Sprintf(" %-22s on %-10s %s: %s", j.Network, j.Link, verdict, j.Why)) + } + if counted == 0 { + out = append(out, " none counts: a port reached from local-network is admitted from the private network only") + } + return out +} diff --git a/cmd/mesh-controller/local_network_test.go b/cmd/mesh-controller/local_network_test.go new file mode 100644 index 00000000..739f39d2 --- /dev/null +++ b/cmd/mesh-controller/local_network_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// **node shows what the local network resolved to** (novox/hq ADR 0340 §8): every attached network, +// whether it counts, and why the others do not — the public subnet named as never counted. +func TestNodeShowsWhichAttachedNetworksCountAndWhy(t *testing.T) { + account := inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", Machine: &inventory.MadeOf{ + Networks: []inventory.AttachedNetwork{ + {Link: "eth0", Network: "192.168.1.10/24"}, + {Link: "eth1", Network: "203.0.113.7/24"}, + {Link: "wg-mesh", Network: "10.10.0.4/24"}, + }}} + judged := judgedNetworks(account, true, []string{"eth0", "eth1"}, "wg-mesh") + text := strings.Join(localNetworkLines(account, true, judged), "\n") + for _, want := range []string{ + "192.168.1.0/24", "counts: in a private range (RFC 1918, 192.168.0.0/16)", + "203.0.113.0/24", "not counted: public, never counted", + "10.10.0.0/24", "the private network's own link", + } { + if !strings.Contains(text, want) { + t.Errorf("node does not say %q:\n%s", want, text) + } + } +} + +// A machine that said none, or whose node-engine is older, is said as that — never as an empty list +// that reads like a machine on no network. +func TestNodeSaysWhenNoNetworkWasSaid(t *testing.T) { + older := inventory.MachineAccount{Machine: &inventory.MadeOf{}} + if text := strings.Join(localNetworkLines(older, true, nil), "\n"); !strings.Contains(text, "older than the one that says it") { + t.Errorf("an older node-engine is not said:\n%s", text) + } + public := inventory.MachineAccount{Machine: &inventory.MadeOf{ + Networks: []inventory.AttachedNetwork{{Link: "eth0", Network: "198.51.100.9/24"}}}} + judged := judgedNetworks(public, true, []string{"eth0"}, "wg-mesh") + if text := strings.Join(localNetworkLines(public, true, judged), "\n"); !strings.Contains(text, "none counts") { + t.Errorf("a machine on a public network only is not said to have no local network:\n%s", text) + } + if text := strings.Join(localNetworkLines(inventory.MachineAccount{}, false, nil), "\n"); !strings.Contains(text, "not said") { + t.Errorf("a machine that never reported is not said:\n%s", text) + } +} + +// The preview of a flip says what a port from the local network keeps: the local networks it stays +// open to, or that the machine reported none (ADR 0340 §6). +func TestTheFlipPreviewSaysWhatALocalNetworkPortKeeps(t *testing.T) { + rules := []catalogue.Rule{{Port: 1883, Protocol: "tcp", From: catalogue.FromLocalNetwork, Because: []string{"mosquitto"}}} + at := inventory.Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 1883} + with := derivedFilter{rules: rules, mesh: []string{"10.10.0.1"}, outwardLinks: []string{"eth0"}, + local: []string{"192.168.1.0/24"}} + if fate := with.fate(at); !strings.Contains(fate, "192.168.1.0/24") { + t.Errorf("the preview says %q, without the local network it stays open to", fate) + } + with.local = nil + if fate := with.fate(at); !strings.Contains(fate, "reported none") { + t.Errorf("the preview says %q, without saying no local network was reported", fate) + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index bc29e048..c446da3d 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -19,6 +19,7 @@ import ( "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/overlay" "github.com/novox/mesh-controller/internal/token" ) @@ -884,6 +885,16 @@ func showNode(ctx context.Context, stored *stores, name string) error { for _, line := range machineLines(account, had, time.Now()) { fmt.Println(line) } + // And which of the networks it is attached to count as its local network, and why the others + // do not (novox/hq ADR 0340 §8). + outward, err := inv.OutwardLinksOf(ctx, name) + if err != nil { + fmt.Printf(" its local network could NOT be judged: %v\n", err) + } else { + for _, line := range localNetworkLines(account, had, judgedNetworks(account, had, outward, overlay.Interface)) { + fmt.Println(line) + } + } } held, err := inv.Profile(ctx, name) diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 952fe892..a41409d6 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -451,6 +451,12 @@ func declarationWith(ctx context.Context, open *stores, node string, if err != nil { return sendable{}, err } + // Which ports the filter admits from the local network, and from which networks, for plan to say + // (novox/hq ADR 0340 §8). The same rules the composition rendered, never sent. + rules, err := plan.Rules(with) + if err != nil { + return sendable{}, err + } // And what was taken on it, said in every declaration it is sent from this one place. adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed) if err != nil { @@ -460,7 +466,8 @@ func declarationWith(ctx context.Context, open *stores, node string, Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld, unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced, - modules: namedModules(plan, composed.LeftOut)} + modules: namedModules(plan, composed.LeftOut), + localNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks)} // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // 0221). Read only on the send path: a question about what would be sent records nothing. if choosing == Allocating { @@ -969,6 +976,12 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + // And which of the networks it is attached to count as its local network (novox/hq ADR 0340): + // private ranges only, on a link facing outside it. Reported by the machine, never set. + local, err := localNetworksOf(ctx, inv, node) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR // 0222): the artifact store as this network reaches it, which the container runtime is told to // trust (ADR 0082). The same address composed into every reference the mesh built. @@ -993,7 +1006,8 @@ func renderingFor(ctx context.Context, open *stores, node string, Machines: machines, Zones: zones, Holders: replicas, Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks, - Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built, + LocalNetworks: catalogue.CountedLocalNetworks(local), + Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built, BusUsers: busUsers, Withheld: withheld, Unbound: unbound, }, record, nil } @@ -1426,6 +1440,11 @@ func planCommand(ctx context.Context, args []string) error { fmt.Printf(" %-20s settings from %s\n", module, layer.From) } } + // What each port reached from the local network resolved to on this machine (novox/hq ADR 0340 §8), + // or that it resolved to none. + for _, p := range declared.localNetwork { + fmt.Println(" " + p.Said()) + } fmt.Printf("\n%d resource(s)\n", len(resources)) if *show { diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 0c1e2116..973813b1 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -40,6 +40,9 @@ type sendable struct { actingEpoch uint64 // modules are the modules this declaration names, for the record of the send; never on the wire. modules []string + // localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR + // 0340 §8), for plan to say; never on the wire. + localNetwork []catalogue.LocalNetworkPort // Adoption is nil for a converged node, and then the body is byte for byte what it was before // adoption existed: an older host parses the envelope strictly and would refuse the key. Adoption *adoptionEnvelope diff --git a/internal/catalogue/adoption.go b/internal/catalogue/adoption.go index 6debcb5e..72bc5645 100644 --- a/internal/catalogue/adoption.go +++ b/internal/catalogue/adoption.go @@ -87,6 +87,11 @@ func Openings(rules []Rule, foundation []int, published map[string]map[int]int) widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere) case FromMesh: widen(key{rule.Protocol, rule.Port}, OpeningFromMesh) + case FromLocalNetwork: + // The private network's part only (novox/hq ADR 0340). An adopted node's found firewall + // stays, and it is what admits the machine's local network; an opening has no word for a + // local network, and widening it to everywhere would open the port to the internet. + widen(key{rule.Protocol, rule.Port}, OpeningFromMesh) } } for _, port := range foundation { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 9da018fe..8f0edaf4 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -135,6 +135,12 @@ type Rendering struct { // composes no filter for it rather than writing a rule around a link with no name. OutwardLinks []string + // LocalNetworks is this machine's local networks (novox/hq ADR 0340): the networks it reported on a + // link facing outside it that lie wholly in a private range (JudgeLocalNetworks), which a port + // reached from `local-network` is admitted from beside the private network. Empty when it reported + // none, and the rule set then says so rather than widening the port or dropping it. + LocalNetworks []string + // TunnelInterface is the interface the mesh's private network runs on, named here rather than // imported because the overlay package rests on this one. Traffic arriving on it is the mesh's, // not this machine's own guest, so the filter admits it only by a rule. @@ -527,8 +533,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string, "`node show %s` says whether it has. Until then %s is not sent, and the machine "+ "keeps the filter it has", r.Node, r.Node, filters) } - filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation, - with.OutwardLinks, with.TunnelInterface) + filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation, + with.OutwardLinks, with.TunnelInterface, with.LocalNetworks) // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR // 0203 §5): the account has one environment, and a machine whose holder arrives later should not diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go index b81bd10d..797276e3 100644 --- a/internal/catalogue/filtering.go +++ b/internal/catalogue/filtering.go @@ -152,9 +152,10 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) { m.Module, port) } source, ok := value.(string) - if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) { - return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q", - m.Module, port, value, FromMesh, FromEverywhere, FromMachine) + if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine && + source != FromLocalNetwork) { + return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q, %q or %q", + m.Module, port, value, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork) } out[port] = source } @@ -172,7 +173,9 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) { // the appearance-of-restriction this mechanism exists to remove. The narrower rule's sources are // carried onto the wider one, so the module that wanted it is still named. func widest(rules []Rule) []Rule { - reach := map[string]int{FromMachine: 0, FromMesh: 1, FromEverywhere: 2} + // The local network is the private network and the machine's own local networks (novox/hq ADR + // 0340 §4), so it covers `mesh` and is covered by `anywhere`. + reach := map[string]int{FromMachine: 0, FromMesh: 1, FromLocalNetwork: 2, FromEverywhere: 3} var out []Rule for _, rule := range rules { covered := false @@ -232,6 +235,15 @@ const SSHPort = 22 // any module asks for, and neither may be derived away. func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, outwardLinks []string, tunnel string) string { + return AsNftablesWithLocal(rules, mesh, outward, foundation, outwardLinks, tunnel, nil) +} + +// AsNftablesWithLocal is AsNftables with the machine's local networks: the networks it reported on a +// link facing outside that count (novox/hq ADR 0340, JudgeLocalNetworks), which a rule from +// `local-network` admits on its own port alone, beside the private network. None is not widened to +// anywhere and not dropped silently: the rule set says the port wanted the local network and found none. +func AsNftablesWithLocal(rules []Rule, mesh []string, outward bool, foundation []int, + outwardLinks []string, tunnel string, local []string) string { // The links that are not this machine's own: the ones facing outside, and the mesh's tunnel. // Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere // else is this machine's own guest and is not something the mesh has a position on. @@ -394,6 +406,31 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n", strings.Join(six, ", "), rule.Protocol, rule.Port)) } + case FromLocalNetwork: + // The private network and the machine's own local networks, on this port alone (novox/hq + // ADR 0340 §5). + four, six := byFamily(mesh) + lfour, lsix := byFamily(local) + four, six = append(four, lfour...), append(six, lsix...) + if len(lfour) == 0 && len(lsix) == 0 { + b.WriteString(fmt.Sprintf( + "\t\t# %s/%d wanted the local network, and this machine reported no private network "+ + "on a link facing outside; admitted from no local network\n", rule.Protocol, rule.Port)) + } + if len(four) == 0 && len(six) == 0 { + b.WriteString(fmt.Sprintf( + "\t\t# %s/%d wanted the local network, and this node knows neither mesh addresses nor a "+ + "local network; closed\n", rule.Protocol, rule.Port)) + break + } + if len(four) > 0 { + b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n", + strings.Join(four, ", "), rule.Protocol, rule.Port)) + } + if len(six) > 0 { + b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n", + strings.Join(six, ", "), rule.Protocol, rule.Port)) + } case FromEverywhere: b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port)) } @@ -487,6 +524,18 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n", strings.Join(six, ", "), rule.Port)) } + case FromLocalNetwork: + four, six := byFamily(mesh) + lfour, lsix := byFamily(local) + four, six = append(four, lfour...), append(six, lsix...) + if len(four) > 0 { + b.WriteString(fmt.Sprintf("\t\tip saddr { %s } ct original proto-dst %d accept\n", + strings.Join(four, ", "), rule.Port)) + } + if len(six) > 0 { + b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n", + strings.Join(six, ", "), rule.Port)) + } case FromEverywhere: b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port)) } @@ -793,10 +842,14 @@ const ( // are a subset of anywhere. What differs is the names, which is the whole reason reach is not // simply the filter's vocabulary with nicer words. ReachBoth = "both" + // ReachLocalNetwork is `internal` with the machine's own local networks added to what the filter + // opens (novox/hq ADR 0340 §4): the internal name and not the public one, the mesh's own authority, + // and the port opened to the private network and to the private networks the machine is attached to. + ReachLocalNetwork = "local-network" ) // reaches is every value, in the order a refusal lists them. -var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth} +var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth, ReachLocalNetwork} // RoutedPorts are the ports a module serves through a proxy, taken from its route contributions. // @@ -845,6 +898,8 @@ func FilterSource(reach string) (string, bool) { return FromMesh, true case ReachPublic, ReachBoth: return FromEverywhere, true + case ReachLocalNetwork: + return FromLocalNetwork, true default: return "", false } @@ -854,7 +909,9 @@ func FilterSource(reach string) (string, bool) { func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth } // WantsInternalName is whether a reach asks for the route's internal name to be composed. -func WantsInternalName(reach string) bool { return reach == ReachInternal || reach == ReachBoth } +func WantsInternalName(reach string) bool { + return reach == ReachInternal || reach == ReachBoth || reach == ReachLocalNetwork +} // Reaches reads a module's per-node reach settings: declared port → how far it reaches. // diff --git a/internal/catalogue/localnetwork.go b/internal/catalogue/localnetwork.go new file mode 100644 index 00000000..d153e7fd --- /dev/null +++ b/internal/catalogue/localnetwork.go @@ -0,0 +1,154 @@ +package catalogue + +import ( + "fmt" + "net/netip" + "slices" + "sort" + "strings" +) + +// AttachedNetwork is one network a machine said it is directly attached to: the link it is on and the +// network as address and prefix (novox/hq ADR 0340 §7), as the node-engine reports it among its facts. +type AttachedNetwork struct { + Link string `json:"link"` + Network string `json:"network"` +} + +// LocalNetwork is one attached network judged: whether it counts as this machine's local network for a +// port reached from `local-network`, and why — said for one that counts as well as for one that does +// not, so `node` can show which counted and why the others did not (ADR 0340 §8). +type LocalNetwork struct { + Link string + Network string + Counts bool + Why string +} + +// privateRanges are the only ranges a local network may lie in (ADR 0340 §2): RFC 1918 for IPv4 and the +// unique local addresses for IPv6. A network counts only when it lies wholly inside one of them. +var privateRanges = []struct { + prefix netip.Prefix + name string +}{ + {netip.MustParsePrefix("10.0.0.0/8"), "a private range (RFC 1918, 10.0.0.0/8)"}, + {netip.MustParsePrefix("172.16.0.0/12"), "a private range (RFC 1918, 172.16.0.0/12)"}, + {netip.MustParsePrefix("192.168.0.0/16"), "a private range (RFC 1918, 192.168.0.0/16)"}, + {netip.MustParsePrefix("fc00::/7"), "the IPv6 unique local range (fc00::/7)"}, +} + +// JudgeLocalNetworks says, for each network a machine reported, whether it counts as its local network. +// +// **A public subnet never counts** (ADR 0340 §2): a network counts only when it lies wholly in a private +// range, so `local-network` can never open a port to the internet — a machine sitting on a public /24 has +// that /24 judged and refused, never rendered. A link-local network does not count either. And only a +// link the machine reported as facing outside it counts (§3): the private network's own link and a +// container bridge are this machine's own, not its local network, whatever their addresses. +// +// Sorted by link and then network, one entry per (link, network), so what `node` shows and what the +// filter is rendered from do not depend on the order a machine happened to say them in. +func JudgeLocalNetworks(attached []AttachedNetwork, outward []string, tunnel string) []LocalNetwork { + seen := map[[2]string]bool{} + var out []LocalNetwork + for _, a := range attached { + key := [2]string{a.Link, a.Network} + if seen[key] { + continue + } + seen[key] = true + out = append(out, judgeOne(a, outward, tunnel)) + } + 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 +} + +func judgeOne(a AttachedNetwork, outward []string, tunnel string) LocalNetwork { + j := LocalNetwork{Link: a.Link, Network: a.Network} + prefix, err := netip.ParsePrefix(strings.TrimSpace(a.Network)) + if err != nil { + j.Why = "not a network the mesh can read (" + err.Error() + ")" + return j + } + prefix = prefix.Masked() + j.Network = prefix.String() + switch { + case tunnel != "" && a.Link == tunnel: + j.Why = "the private network's own link, not the local network" + return j + case !slices.Contains(outward, a.Link): + j.Why = fmt.Sprintf("%s does not face outside this machine (a bridge or a link of its own)", a.Link) + return j + case prefix.Addr().IsLoopback(): + j.Why = "loopback" + return j + case prefix.Addr().IsLinkLocalUnicast(): + j.Why = "link-local, never counted" + return j + } + for _, r := range privateRanges { + if r.prefix.Contains(prefix.Addr()) && prefix.Bits() >= r.prefix.Bits() { + j.Counts = true + j.Why = "in " + r.name + return j + } + } + if prefix.Addr().IsPrivate() { + // Overlaps a private range without lying wholly inside it (a /7 around 10.0.0.0/8, say). + j.Why = "not wholly in a private range, so never counted" + return j + } + j.Why = "public, never counted: local-network may not open a port to the internet" + return j +} + +// CountedLocalNetworks is the networks that count, as the filter renders them: sorted, without repeats. +func CountedLocalNetworks(judged []LocalNetwork) []string { + var out []string + for _, j := range judged { + if j.Counts && !slices.Contains(out, j.Network) { + out = append(out, j.Network) + } + } + sort.Strings(out) + return out +} + +// LocalNetworkPort is one port the filter admits from `local-network`, and what that resolved to on this +// machine (ADR 0340 §8): what `plan` shows for it. +type LocalNetworkPort struct { + Port int + Protocol string + Because []string + // Networks is the local networks it is admitted from; empty when the machine reported none (§6). + Networks []string +} + +// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to. +func LocalNetworkPorts(rules []Rule, networks []string) []LocalNetworkPort { + var out []LocalNetworkPort + for _, rule := range rules { + if rule.From != FromLocalNetwork { + continue + } + out = append(out, LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol, + Because: rule.Because, Networks: networks}) + } + return out +} + +// Said is how `plan` says one such port: the networks, or that there are none and the port is admitted +// from the private network only. +func (p LocalNetworkPort) Said() string { + by := strings.Join(p.Because, ", ") + if len(p.Networks) == 0 { + return fmt.Sprintf("%s/%d (%s) from the local network: none — this machine reported no private "+ + "network on a link facing outside, so it is admitted from the private network only", p.Protocol, p.Port, by) + } + return fmt.Sprintf("%s/%d (%s) from the local network: %s, and the private network", p.Protocol, p.Port, by, + strings.Join(p.Networks, ", ")) +} diff --git a/internal/catalogue/localnetwork_test.go b/internal/catalogue/localnetwork_test.go new file mode 100644 index 00000000..4a0cd6f9 --- /dev/null +++ b/internal/catalogue/localnetwork_test.go @@ -0,0 +1,195 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The home server's case (novox/hq issue 154): it sits on 192.168.1.0/24, and also on a public /24 a +// provider handed it, beside the private network's own link and a container bridge. +var homeServer = []AttachedNetwork{ + {Link: "eth0", Network: "192.168.1.10/24"}, + {Link: "eth1", Network: "203.0.113.7/24"}, + {Link: "eth0", Network: "fe80::1/64"}, + {Link: "eth0", Network: "fd12:3456:789a::5/64"}, + {Link: "wg-mesh", Network: "10.10.0.4/24"}, + {Link: "docker0", Network: "172.17.0.1/16"}, +} + +func judged(t *testing.T) []LocalNetwork { + t.Helper() + return JudgeLocalNetworks(homeServer, []string{"eth0", "eth1"}, "wg-mesh") +} + +// **A public subnet never counts** (ADR 0340 §2), and only a private one on a link facing outside does +// (§3): the tunnel's and a bridge's are this machine's own, whatever their addresses. +func TestOnlyAPrivateNetworkOnALinkFacingOutsideCounts(t *testing.T) { + got := CountedLocalNetworks(judged(t)) + want := []string{"192.168.1.0/24", "fd12:3456:789a::/64"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("counted %v, want %v", got, want) + } + for _, j := range judged(t) { + if j.Why == "" { + t.Errorf("%s on %s is judged without a why", j.Network, j.Link) + } + switch j.Network { + case "203.0.113.0/24": + if j.Counts || !strings.Contains(j.Why, "public") { + t.Errorf("the public subnet is judged %v (%s), want never counted as public", j.Counts, j.Why) + } + case "fe80::/64": + if j.Counts || !strings.Contains(j.Why, "link-local") { + t.Errorf("a link-local network is judged %v (%s)", j.Counts, j.Why) + } + case "10.10.0.0/24", "172.17.0.0/16": + if j.Counts { + t.Errorf("%s on %s counts, and it is not the local network", j.Network, j.Link) + } + } + } +} + +// A network overlapping a private range without lying wholly inside it does not count either, so a +// wide prefix around a private range cannot carry the internet with it. +func TestANetworkNotWhollyInAPrivateRangeDoesNotCount(t *testing.T) { + for _, n := range []string{"10.0.0.0/7", "172.0.0.0/8", "100.64.0.0/10", "0.0.0.0/0", "::/0"} { + got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: n}}, []string{"eth0"}, "") + if len(got) != 1 || got[0].Counts { + t.Errorf("%s counts as a local network: %+v", n, got) + } + } +} + +// **The filter admits the local network on that port's rule alone** (§5), beside the private network, +// in both chains, and never the public subnet. +func TestALocalNetworkPortIsAdmittedFromItsNetworksAndTheMesh(t *testing.T) { + rules := []Rule{ + {Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}}, + {Port: 5432, Protocol: "tcp", From: FromMesh, Because: []string{"postgres"}}, + } + local := CountedLocalNetworks(judged(t)) + out := AsNftablesWithLocal(rules, []string{"10.10.0.1", "10.10.0.4"}, false, nil, + []string{"eth0", "eth1"}, "wg-mesh", local) + + for _, want := range []string{ + "ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } tcp dport 1883 accept", + "ip6 saddr { fd12:3456:789a::/64 } tcp dport 1883 accept", + "ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } ct original proto-dst 1883 accept", + // The mesh-only port is not widened by the machine having a local network. + "ip saddr { 10.10.0.1, 10.10.0.4 } tcp dport 5432 accept", + } { + if !strings.Contains(out, want) { + t.Errorf("the rule set lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "203.0.113") { + t.Errorf("the public subnet reached the rule set:\n%s", out) + } + if strings.Contains(out, "tcp dport 1883 accept\n") && !strings.Contains(out, "saddr") { + t.Errorf("1883 is open to anywhere:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(line) == "tcp dport 1883 accept" { + t.Fatalf("1883 is open to anywhere:\n%s", out) + } + } +} + +// **Nothing resolved is closed and said** (§6): a machine that reported no private network admits the +// port from the private network only, and the rule set says it found none — never widened to anywhere. +func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) { + rules := []Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}}} + out := AsNftablesWithLocal(rules, []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "wg-mesh", nil) + if !strings.Contains(out, "1883 wanted the local network, and this machine reported no private network") { + t.Errorf("the rule set does not say the local network resolved to nothing:\n%s", out) + } + if !strings.Contains(out, "ip saddr { 10.10.0.1 } tcp dport 1883 accept") { + t.Errorf("the port is not admitted from the private network:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(line) == "tcp dport 1883 accept" { + t.Fatalf("an unresolved local network was widened to anywhere:\n%s", out) + } + } + said := LocalNetworkPorts(rules, nil)[0].Said() + if !strings.Contains(said, "none") { + t.Errorf("plan says %q, want it to say no local network was found", said) + } +} + +// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is +// still refused. +func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) { + if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + "listens":[{"port":1883,"from":"local-network"}]}`)); err != nil { + t.Fatalf("a listen from the local network is refused: %v", err) + } + _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + "listens":[{"port":1883,"from":"lan"}]}`)) + if err == nil || !strings.Contains(err.Error(), `from "lan"`) { + t.Fatalf("a source the mesh cannot render is not refused: %v", err) + } +} + +// **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the +// filter opens — the internal name, not the public one. +func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) { + public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachLocalNetwork)) + if public != "" || internal != "app.anchor.internal" { + t.Fatalf("names are %q and %q, want the internal name only", public, internal) + } + bare := aRoutedWeb() + bare.Contributes = nil + r := Resolution{Node: "anchor", Modules: []Manifest{bare}} + rules, err := r.Rules(Rendering{Settings: reachSet(ReachLocalNetwork)}) + if err != nil { + t.Fatalf("rules: %v", err) + } + if len(rules) != 1 || rules[0].From != FromLocalNetwork { + t.Fatalf("reach local-network made the filter say %+v", rules) + } +} + +// Two modules wanting one port, one from the mesh and one from the local network, render one rule from +// the local network: it covers the mesh, and anywhere covers it. +func TestTheLocalNetworkSitsBetweenTheMeshAndAnywhere(t *testing.T) { + got := widest([]Rule{ + {Port: 53, Protocol: "udp", From: FromMesh, Because: []string{"a"}}, + {Port: 53, Protocol: "udp", From: FromLocalNetwork, Because: []string{"b"}}, + }) + if len(got) != 1 || got[0].From != FromLocalNetwork || len(got[0].Because) != 2 { + t.Fatalf("got %+v", got) + } + got = widest(append(got, Rule{Port: 53, Protocol: "udp", From: FromEverywhere, Because: []string{"c"}})) + if len(got) != 1 || got[0].From != FromEverywhere { + t.Fatalf("got %+v", got) + } +} + +// On an adopted node the opening is the private network's part only: the found firewall admits the +// local network, and an opening that widened to everywhere would open the port to the internet. +func TestAnAdoptedNodeOpensTheLocalNetworkPortToTheMeshOnly(t *testing.T) { + out := Openings([]Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork}}, nil, nil) + if len(out) != 1 || out[0]["from"] != OpeningFromMesh { + t.Fatalf("openings %v", out) + } +} + +// A global unicast IPv6 subnet on a link facing outside is public, and never counts: the IPv6 side of the +// rule that `local-network` cannot open a port to the internet (ADR 0340 §2). +func TestAGlobalIPv6SubnetIsPublicAndNeverCounts(t *testing.T) { + got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: "2001:db8:1234::7/48"}}, []string{"eth0"}, "wg-mesh") + if len(got) != 1 { + t.Fatalf("judged %+v", got) + } + if got[0].Counts { + t.Fatalf("the global IPv6 subnet %s counts as a local network", got[0].Network) + } + if got[0].Network != "2001:db8:1234::/48" || !strings.Contains(got[0].Why, "public, never counted") { + t.Fatalf("judged %s with why %q, want it named public and never counted", got[0].Network, got[0].Why) + } + if n := CountedLocalNetworks(got); len(n) != 0 { + t.Fatalf("counted %v", n) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 25a200ec..866daf78 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1165,6 +1165,12 @@ const ( FromEverywhere = "anywhere" // FromMachine is this machine only — a port bound for something else on the same host. FromMachine = "machine" + // FromLocalNetwork is the private network and the networks this machine is directly attached to + // through its uplink, as it reports them, and only those wholly in a private range (novox/hq ADR + // 0340): a local service reached by devices that will never be nodes, said as exactly that rather + // than as `anywhere`. A public subnet the machine sits on never counts, so it cannot open a port to + // the internet. + FromLocalNetwork = "local-network" ) // At is this port's protocol, with the default applied. @@ -1929,17 +1935,17 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s listens on port %d, which is not a port", m.Module, l.Port)) } switch l.From { - case FromMesh, FromEverywhere, FromMachine: + case FromMesh, FromEverywhere, FromMachine, FromLocalNetwork: case "": // The fault this field exists to prevent. A rule with no source is open, and a // manifest that omitted it would read as a restriction and be none. problems = append(problems, fmt.Sprintf( - "%s listens on %d and does not say from where; it is %q, %q or %q", - m.Module, l.Port, FromMesh, FromEverywhere, FromMachine)) + "%s listens on %d and does not say from where; it is %q, %q, %q or %q", + m.Module, l.Port, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)) default: problems = append(problems, fmt.Sprintf( - "%s listens on %d from %q; it is %q, %q or %q", - m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine)) + "%s listens on %d from %q; it is %q, %q, %q or %q", + m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)) } if p := l.At(); p != "tcp" && p != "udp" { problems = append(problems, fmt.Sprintf( diff --git a/internal/inventory/machine.go b/internal/inventory/machine.go index 9934eddc..923cc832 100644 --- a/internal/inventory/machine.go +++ b/internal/inventory/machine.go @@ -36,7 +36,16 @@ type MadeOf struct { BootedAt *time.Time `json:"booted_at"` Timezone string `json:"timezone"` Filesystems []MountedFilesystem `json:"filesystems"` - Unknown map[string]string `json:"unknown"` + // Networks is every network the machine is directly attached to on a link facing outside it, as + // link and network (novox/hq ADR 0340 §7): what a port reached from `local-network` resolves from. + Networks []AttachedNetwork `json:"networks"` + Unknown map[string]string `json:"unknown"` +} + +// AttachedNetwork is one network a machine said it is attached to, and the link it is on. +type AttachedNetwork struct { + Link string `json:"link"` + Network string `json:"network"` } // MountedFilesystem is one local filesystem a machine has mounted. diff --git a/internal/inventory/machine_test.go b/internal/inventory/machine_test.go index a30475c2..2c1eb02e 100644 --- a/internal/inventory/machine_test.go +++ b/internal/inventory/machine_test.go @@ -40,3 +40,18 @@ func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) { t.Error("a node that never reported read as having reported") } } + +// The networks a machine is attached to (novox/hq ADR 0340 §7), in the node-engine's words: what a port +// reached from `local-network` resolves from. +func TestAMachinesAttachedNetworksAreRead(t *testing.T) { + a, had, err := machineFrom([]byte(`{"architecture":"amd64","kernel":"linux", + "machine":{"observed_at":"2026-10-13T09:00:00Z", + "networks":[{"link":"eth0","network":"192.168.1.0/24"},{"link":"eth1","network":"203.0.113.0/24"}]}}`)) + if err != nil || !had || a.Machine == nil { + t.Fatalf("%+v had %v err %v", a, had, err) + } + n := a.Machine.Networks + if len(n) != 2 || n[0].Link != "eth0" || n[0].Network != "192.168.1.0/24" || n[1].Network != "203.0.113.0/24" { + t.Fatalf("networks read as %+v", n) + } +}