mesh/merge-gate head 37947ca4eed1 merged into trunk d8e785bb4b4b — pass: the change touches no module of the mesh's graph
mesh/repo-check head 37947ca4eed1 merged into trunk d8e785bb4b4b — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The installer could not raise a first node: the old bundle's control plane had no MESH_BUS_NATS and crash-looped, and the NATS bundle was refused because rootsecrets only knew the previous broker's guest:guest@ template. One bundle now, the NATS one. rootsecrets makes the controller's bus account and puts it in both placeholders (the address and the bcrypt hash in the carried user list), kept with its hash so a re-run writes the same bundle. Preflight refuses a template naming no bus connection it knows, naming the placeholder, instead of waiting out the verify step. The broker step adopts the bus as the nats module and refuses, by name, a catalogue without it or a server that is not the foundation's container.
203 lines
8.5 KiB
Go
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, 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
|
|
}
|