Files
mesh-host/internal/bootstrap/adopted.go
T
jochen cb55a46400
mesh/merge-gate head cb55a4640050 merged into trunk 8e95c1d9ed2d — pass: the change touches no module of the mesh's graph
mesh/repo-check head cb55a4640050 merged into trunk 8e95c1d9ed2d — pass: its merge-check.sh passed
mesh/delivery delivered
Place the bus's users again on a re-run until the bus is adopted (hq issue 146, review 102)
A re-run's apply writes the bundle's one-entry user list over the list placed at
enrolment, and an enrolled machine skipped placing it: enrolled was taken for adopted,
against ADR 0331 §5. Until the controller knows the nats module, the installer places
the composed list again. Also drop the previous broker's AMQP and management ports,
which nothing raises any more, and say why the bus always carries a ports setting.
2026-10-12 23:06:33 +02:00

203 lines
8.5 KiB
Go

package bootstrap
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// What an adopted genesis changes about the foundation (novox/hq ADR 0100).
//
// **The firewall found on the machine stays in force.** The foundation's own filter drops by
// default, and every base chain at a hook runs; an accept ends only its own chain and a drop in any
// is final — so loading it would close whatever the machine serves. On an adopted machine it is
// not loaded. Its duty, the store never reachable from outside, passes to the mesh's guard: a table
// of the mesh's own that only refuses, and only the foundation's own ports, which genesis has just
// checked free — so it cannot close anything the machine serves.
// The guard, as the controller declares it: the same ids, paths and text, so the first push
// finds it already there and takes it over unchanged.
const (
guardID = declaration.AdoptionPrefix + "guard"
guardUnitID = declaration.AdoptionPrefix + "guard-unit"
guardRunningID = declaration.AdoptionPrefix + "guard-running"
guardPath = "/etc/mesh/guard.nft"
guardUnit = "mesh-guard.service"
guardUnitPath = "/etc/systemd/system/" + guardUnit
)
// AsGuard renders the mesh's refusal-only table for the given machine ports. It passes everything
// by default; it refuses 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, in the inet family so both address families. It matches only packets addressed to
// this machine: what the machine routes for others is never its business (novox/hq ADR 0103).
//
// Character for character the controller's (mesh-controller internal/catalogue AsGuard); a test
// on each side holds its copy to the same golden text.
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")
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" +
"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 three resources of kinds the host already has.
func guardResources(ports []int) []map[string]any {
return []map[string]any{
{"id": guardID, "type": "file", "path": guardPath, "content": AsGuard(ports), "mode": "0644"},
{"id": guardUnitID, "type": "file", "path": guardUnitPath, "content": guardUnitText(), "mode": "0644"},
// A changed table is reloaded — the unit's ExecReload loads it in one transaction, so the
// ports are never unguarded — and only a changed unit restarts it. As the controller
// declares it, so the first push finds nothing different.
{"id": guardRunningID, "type": "service", "unit": guardUnit, "state": "running",
"boot": "enabled", "reload-on": []any{guardID}, "restart-on": []any{guardUnitID}},
}
}
// guardAfter is where the guard goes: once the container runtime runs, before anything publishes
// a port.
const guardAfter = "container-runtime-running"
// AdoptedRewrite says what RewriteAdopted did.
type AdoptedRewrite struct {
Removed []string
Guarded []int
}
// RewriteAdopted makes the produced bundle one for an adopted machine: the foundation's own filter
// taken out, and the mesh's guard put in its place, guarding on this node the store's port. It is
// published on every interface and the foundation's filter admits it from the private network only,
// so a found firewall that filters only incoming traffic would leave it reachable from anywhere
// (novox/hq ADR 0103). The bus is reached from anywhere on purpose — a node enrols over it — and its
// monitoring port is published on loopback only, so neither is guarded. The nftables package stays: the guard is loaded with it, and
// installing a package loads no table. Openings are not the bundle's — the first push declares
// them, once there is a controller to derive them.
func RewriteAdopted(r *Rewritten, p FoundationPorts) (AdoptedRewrite, error) {
var out AdoptedRewrite
p = p.orDefaults()
bundle := r.Bundle
var err error
for _, id := range []string{"base-filter-loaded", "base-filter"} {
if !r.declares(id) {
continue
}
if bundle, err = removeResource(bundle, id); err != nil {
return out, err
}
out.Removed = append(out.Removed, id)
}
out.Guarded = []int{p.Store}
var text bytes.Buffer
text.WriteString(",\n // The mesh's guard (novox/hq ADR 0100): this machine is adopted, so its own firewall\n" +
" // stays in force and the foundation's filter is not loaded. The guard only refuses: the\n" +
" // store's port, except from the machine and\n" +
" // the private network.")
for _, res := range guardResources(out.Guarded) {
var one bytes.Buffer
enc := json.NewEncoder(&one)
enc.SetEscapeHTML(false)
if err := enc.Encode(res); err != nil {
return out, err
}
text.WriteString("\n ")
text.Write(bytes.TrimSpace(one.Bytes()))
text.WriteString(",")
}
insert := bytes.TrimSuffix(text.Bytes(), []byte(","))
_, _, to, err := resourceAt(bundle, guardAfter)
if err != nil {
return out, fmt.Errorf("the guard goes after %q, and %w", guardAfter, err)
}
rest := bundle[to:]
joined := make([]byte, 0, len(bundle)+len(insert))
joined = append(joined, bundle[:to]...)
joined = append(joined, insert...)
// What followed the resource — its own comma, or the end of the list — now follows the guard.
if trimmed := bytes.TrimLeft(rest, " \t\r\n"); len(trimmed) > 0 && trimmed[0] != ',' && trimmed[0] != ']' {
return out, fmt.Errorf("the bundle does not separate %q from what follows it the way a list does", guardAfter)
}
joined = append(joined, rest...)
parsed, err := declaration.ParseFileTrusted(joined)
if err != nil {
return out, fmt.Errorf("the bundle stopped being a declaration once it was made an adopted one, which is this installer's fault: %w", err)
}
r.Bundle, r.Declaration, r.Resources = joined, parsed, len(parsed.Resources)
return out, nil
}
// declares is whether the produced bundle names a resource.
func (r Rewritten) declares(id string) bool {
for _, res := range r.Declaration.Resources {
if res.Identity() == id {
return true
}
}
return false
}
// genesisTakes are the modules an adopted genesis takes as it installs them: the foundation's and
// the mesh's own, whose names genesis checked free, so taking them replaces nothing a predecessor
// ran. The private network is not among them — it rewrites the machine's hosts file and the
// container runtime's configuration whole — and neither is anything the operator installs later.
var genesisTakes = map[string]bool{
RegistryModule: true, ControlPlaneModule: true, BuilderModule: true,
"postgres": true, BusModule: true, "mesh-vault": true, "mesh-catalog": true,
}
// takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and
// before the push that raises it.
func takeIfAdopted(ctx context.Context, o Options, control controlPlane, module string, say func(string)) error {
if !o.Adopted || !genesisTakes[module] {
return nil
}
if _, err := control.tell(ctx, "take", o.Node, module); err != nil {
return err
}
say(" taken " + module + " on " + o.Node + " — the mesh's own, its name checked free")
return nil
}