package catalogue import ( "fmt" "sort" "strconv" "strings" ) // What an adopted node is declared in place of a filter (novox/hq ADR 0100). // // On an adopted node the firewall found on the machine stays in force: the mesh loads no table // that drops by default or holds an accept. What the mesh needs reachable is declared as // openings, which the host converges through the found firewall in its own terms; and the mesh // guards its own foundation ports itself, in a table that only refuses. // AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is // never a module's, so none of it is ever held as found. const AdoptionPrefix = "adoption." // Where an opening admits from, on the wire. const ( OpeningFromEverywhere = "everywhere" OpeningFromMesh = "mesh" ) // The two paths a packet reaches a port by: received by the machine, or forwarded to a // container that publishes it. const ( PathIncoming = "incoming" PathForwarded = "forwarded" ) // Guard resources: the refusal-only table, the unit that loads it, and that unit running. const ( GuardPath = "/etc/mesh/guard.nft" GuardUnit = "mesh-guard.service" // GuardUnitPath is where the unit is written. GuardUnitPath = "/etc/systemd/system/" + GuardUnit ) // GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer // raises the same three on an adopted genesis, so the first push finds them already there. func GuardID() string { return AdoptionPrefix + "guard" } func GuardUnitID() string { return AdoptionPrefix + "guard-unit" } func GuardRunningID() string { return AdoptionPrefix + "guard-running" } // GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has // no filter module and may have no nft at all, and a table nothing can load guards nothing. func GuardPackageID() string { return AdoptionPrefix + "guard-package" } // GuardPackage is the package that carries nft. const GuardPackage = "nftables" // OpeningID is an opening's resource identity: its protocol, port and path say what it is. func OpeningID(protocol string, port int, path string) string { return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path) } // Openings are what the mesh needs reachable on an adopted node, from the same inputs as the // filter it would load were the node converged, each from where that filter would admit it. // // `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure — // and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine // only opens nothing. `published` maps a machine port a container publishes to the container's // port: a published port is forwarded, not received, so its opening names the forwarded path and // the port the packet is forwarded to. func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any { type key struct { protocol string port int } from := map[key]string{} var order []key widen := func(k key, f string) { was, seen := from[k] if !seen { order = append(order, k) } if !seen || was != OpeningFromEverywhere { from[k] = f } } for _, rule := range rules { switch rule.From { case FromEverywhere: widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere) case FromMesh: widen(key{rule.Protocol, rule.Port}, OpeningFromMesh) } } for _, port := range foundation { widen(key{"tcp", port}, OpeningFromEverywhere) } sort.Slice(order, func(a, b int) bool { if order[a].port != order[b].port { return order[a].port < order[b].port } return order[a].protocol < order[b].protocol }) out := make([]map[string]any, 0, len(order)) for _, k := range order { opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol, "from": from[k]} if to, forwarded := published[k.protocol][k.port]; forwarded { opening["id"] = OpeningID(k.protocol, k.port, PathForwarded) opening["path"] = PathForwarded opening["to"] = to } else { opening["id"] = OpeningID(k.protocol, k.port, PathIncoming) opening["path"] = PathIncoming } out = append(out, opening) } return out } // Published is every port the given containers publish on the machine, by protocol and machine // port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off // the machine reaches it, forwarded or not. func Published(resources []map[string]any) map[string]map[int]int { out := map[string]map[int]int{} for _, r := range resources { if fmt.Sprint(r["type"]) != "container" { continue } listed, _ := r["ports"].([]any) for _, entry := range listed { written := strings.TrimSpace(fmt.Sprint(entry)) protocol := "tcp" if cut := strings.LastIndex(written, "/"); cut >= 0 { protocol = written[cut+1:] } // Indexed from the end, so an IPv6 address's own colons never shift the ports. outer, inner, address, ok := mapping(written) if !ok { continue } switch strings.Trim(address, "[]") { case "127.0.0.1", "localhost", "::1": continue } if out[protocol] == nil { out[protocol] = map[int]int{} } out[protocol][outer] = inner } } return out } // AsGuard renders the mesh's refusal-only table for the given machine ports. // // It passes everything by default and holds nothing but a refusal, so it cannot close anything // the machine serves; and it is the mesh's own table, so the found firewall reloading does not // touch it. It refuses only packets addressed to this machine, and the ports except from the // machine itself — its loopback and the container runtime's own networks — and from the private // network, known by the interface a packet arrives on and never by its source address. At // prerouting, ahead of the runtime's destination translation, so it matches the port the packet // was sent to; in the inet family, so both address families. // // **The machine's own interfaces are named, where the derived filter names address ranges.** The // filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo, // docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is // named anything else (a podman or libvirt bridge, or a docker network created with a fixed name) // would have its containers' traffic to a guarded port refused, which reads as the port being // down. Names rather than addresses is deliberate: a source address can be claimed by whoever // sends the packet, and this table exists to refuse what the found firewall never sees. Widening // it means adding names here and in the installer's copy together, which the golden test holds to // one text. // // The same text the installer raises on an adopted genesis; a test holds both to it. func AsGuard(ports []int) string { sorted := append([]int{}, ports...) sort.Ints(sorted) listed := make([]string, len(sorted)) for i, p := range sorted { listed[i] = strconv.Itoa(p) } var b strings.Builder b.WriteString("table inet mesh_guard {}\n") b.WriteString("delete table inet mesh_guard\n") b.WriteString("table inet mesh_guard {\n") b.WriteString("\tchain prerouting {\n") b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n") // Only packets addressed to this machine: traffic it routes for others — a predecessor's hub, // say — is never the guard's business (novox/hq ADR 0103). fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+ "iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n", strings.Join(listed, ", ")) b.WriteString("\t}\n") b.WriteString("}\n") return b.String() } // GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never // a flush, which would take the container runtime's rules and the found firewall with it. func GuardUnitText() string { return "[Unit]\n" + "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" + // Early, before the network is up, and without the default dependencies that would // order it after the network; stopped at shutdown like any unit. "DefaultDependencies=no\n" + "Wants=network-pre.target\n" + "Before=network-pre.target shutdown.target\n" + "Conflicts=shutdown.target\n" + "\n" + "[Service]\n" + "Type=oneshot\n" + "RemainAfterExit=yes\n" + "ExecStart=nft -f " + GuardPath + "\n" + "ExecReload=nft -f " + GuardPath + "\n" + "ExecStop=nft delete table inet mesh_guard\n" + "\n" + "[Install]\n" + "WantedBy=multi-user.target\n" } // GuardResources are the guard as four resources of the existing kinds: the tool that loads it, // the table, the unit, and the unit running — reloaded when the table changes, so the new table // replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and // restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty // set is not a table nft loads. func GuardResources(ports []int) []map[string]any { if len(ports) == 0 { return nil } return []map[string]any{ {"id": GuardPackageID(), "type": "package", "package": GuardPackage}, {"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports), "mode": "0644"}, {"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(), "mode": "0644"}, {"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running", "boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}}, } }