mesh/delivery-group group fix/154-local-network checking: 0 of 2 member(s) ready
mesh/merge-gate out of date: judged against trunk 1826b68a22b5, the trunk is now 404b4409e70e; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 1826b68a22b5, the trunk is now 404b4409e70e; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
A local service could only be declared public, which says the internet. local-network is a source and a reach: the private network and the private networks the machine reported on a link facing outside, judged here, shown by plan and node, and said in the rule set when there are none.
243 lines
9.8 KiB
Go
243 lines
9.8 KiB
Go
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)
|
|
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 {
|
|
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()}},
|
|
}
|
|
}
|