ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
178 lines
7.7 KiB
Go
178 lines
7.7 KiB
Go
package resolution
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// 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 mcat.AdoptionPrefix + "guard" }
|
|
|
|
func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" }
|
|
|
|
func GuardRunningID() string { return mcat.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 mcat.AdoptionPrefix + "guard-package" }
|
|
|
|
// 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", mcat.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 []mcat.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 != mcat.OpeningFromEverywhere {
|
|
from[k] = f
|
|
}
|
|
}
|
|
for _, rule := range rules {
|
|
switch rule.From {
|
|
case mcat.FromEverywhere:
|
|
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere)
|
|
case mcat.FromMesh:
|
|
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
|
|
case mcat.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}, mcat.OpeningFromMesh)
|
|
}
|
|
}
|
|
for _, port := range foundation {
|
|
widen(key{"tcp", port}, mcat.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, mcat.PathForwarded)
|
|
opening["path"] = mcat.PathForwarded
|
|
opening["to"] = to
|
|
} else {
|
|
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming)
|
|
opening["path"] = mcat.PathIncoming
|
|
}
|
|
out = append(out, opening)
|
|
}
|
|
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 " + mcat.GuardPath + "\n" +
|
|
"ExecReload=nft -f " + mcat.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": mcat.GuardPackage},
|
|
{"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports),
|
|
"mode": "0644"},
|
|
{"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(),
|
|
"mode": "0644"},
|
|
{"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running",
|
|
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
|
|
}
|
|
}
|