Files
mesh-host/internal/bootstrap/adopted.go
T

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, the
// broker's management port and the broker's plaintext port. The last 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). Every one is a port of a module genesis takes. 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, p.Management, p.AMQP}
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 and the broker's management and plaintext ports, 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, "lavinmq": 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
}