Compare commits

...
Author SHA1 Message Date
jschoubben b0d11c2439 A machine says which of its links face outside
The filter blocks everything passing through the machine and then allows the
machine's own containers back by naming the address ranges they sit on — two
ranges fixed in the control plane and the rest typed after a flip had already
cut a workstation off. A range describes one machine and goes stale in silence.

Read the links carrying a default route instead, from /proc rather than by
asking a program, and report them on every apply. A machine with no route off
itself reports nothing, and the mesh composes no filter for it rather than
writing a rule around a link with no name.

novox/hq ADR 0140. The control plane does not read this yet.
2026-09-28 23:37:06 +02:00
mesh-admin 155689672c Merge pull request 'A container's mesh names are part of what it is' (#44) from fix/a-containers-mesh-names-are-part-of-what-it-is into main 2026-09-28 15:19:39 +00:00
jschoubben e82789a322 A container's mesh names are part of what it is
A container resolves every machine and public name through the entries it is given when it is created,
and nothing re-reads them. The host compared everything about a container except those, so one whose
image and files never changed was left alone holding an overlay address five days out of date — it
restarted 2286 times against a database it could no longer find, and the mesh reported the machine as
doing what it was told (novox/hq 04-ISSUES/135, the same fault as 045 in the field left out).

Sorted, so the digest does not move for a reordering nobody made. The first apply after this recreates
every container that carries mesh names, once.
2026-09-28 17:19:38 +02:00
mesh-admin 99562947f3 Merge pull request 'Genesis lets the control plane state its own facts' (#43) from fix/genesis-lets-the-control-plane-state-its-facts into main 2026-09-28 14:07:22 +00:00
jschoubben c171c64d3a Genesis lets the control plane state its own facts
A mesh raised from nothing must be able to say what it applied and what a machine refused (novox/hq
ADR 0134), and the first user list is what permits it. Kept in step with the controller's own
composition by the test that reads this file.
2026-09-28 16:07:20 +02:00
mesh-admin 0dc5515099 Merge pull request 'A resource's owner is read to the last dot, because a module's name may contain one' (#42) from fix/a-resources-owner-is-read-to-the-last-dot into main 2026-09-28 13:45:01 +00:00
jschoubben 58c0e715c7 A resource's owner is read to the last dot, because a module's name may contain one
novox.be is a module on this mesh. Reading a resource's owner to the first dot made its resources
belong to something called "novox", and a step's gate would then skip whatever else happened to start
that way — silently. A resource's own id never contains a dot, which is what makes the last one the
boundary; the mesh's derived step was changed to add a hyphen rather than a dot for the same reason
(mesh-controller #128).
2026-09-28 15:44:59 +02:00
mesh-admin c5b229f95d Merge pull request 'A step gates its module, not the machine' (#41) from fix/a-step-gates-its-module-not-the-machine into main 2026-09-28 13:38:21 +00:00
8 changed files with 397 additions and 8 deletions
+10
View File
@@ -33,6 +33,7 @@ import (
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/inventory"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/outward"
"github.com/novox/mesh-host/internal/profile"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
@@ -1263,6 +1264,15 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
}
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw)}
// Which of this machine's links face outside, for the filter the mesh writes around them
// (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
// and an adopted one becomes converged without a further round trip. A machine that cannot read
// its own routing table says nothing rather than guessing, and is sent no filter.
if links, err := outward.Links(""); err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: applied, and could not read which links face outside: %v\n", err)
} else {
report.Outward = links
}
// What this node found and holds, its firewall, and what is reachable on it — so an adopted
// node never reads as converged (novox/hq ADR 0100).
for _, h := range updated.Held {
+1 -1
View File
@@ -152,7 +152,7 @@
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.API.>\", \"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"_INBOX.enrol.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.seat.mesh-build-machine.event.built\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.API.>\", \"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"_INBOX.enrol.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.seat.mesh-build-machine.event.built\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
{
"id": "broker",
+26 -6
View File
@@ -1501,6 +1501,20 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s
for _, a := range r.Args {
b.WriteString("arg " + a + "\n")
}
// **The mesh's names are part of what a container is** (novox/hq 04-ISSUES/135). A container
// resolves every other machine and every public name through the entries the mesh gives it at
// creation, and nothing re-reads them afterwards — so a container left alone when the roster
// moved is one that cannot reach anything by name, for ever, while every check reports it
// running. That is exactly what happened when this mesh's overlay range changed: one container
// whose image and files never changed kept an address five days out of date and restarted
// 2286 times against a database it could no longer find.
//
// Sorted, so the digest does not move for a reordering nobody made.
hosts := append([]string(nil), r.Hosts...)
sort.Strings(hosts)
for _, h := range hosts {
b.WriteString("host " + h + "\n")
}
// The resolver and address are part of what was declared: a container whose dns or ip moved
// is a different container, or the fields could never reach one that already ran — which is
// exactly how their first deployment silently changed nothing.
@@ -2281,16 +2295,22 @@ func meshMadeUnits(known store.State) map[string]bool {
// moduleOf is the module a declared resource belongs to.
//
// The mesh composes a module's resource ids as `<module>.<id>`, so the part before the first dot is
// the module. False for what the mesh declares in its own right — a guard, an opening, the adoption's
// own resources — which belongs to no module, and whose gate is therefore the machine's.
// The mesh composes a module's resource ids as `<module>.<its own id>`, and **a module's name may
// contain a dot** — `novox.be` is one on this mesh — while a resource's own id never does. So the
// owner is everything before the *last* dot; reading to the first one would make `novox.be.server`
// belong to a module called "novox", and a gate would then skip whatever else happened to start that
// way.
//
// False for what the mesh declares in its own right: the foundation's resources carry no dot at all,
// and the adoption's are named for the mesh rather than for a module. Both belong to no module, and
// their gate is therefore the machine's.
func moduleOf(identity string) (string, bool) {
if strings.HasPrefix(identity, declaration.AdoptionPrefix) {
return "", false
}
module, _, ok := strings.Cut(identity, ".")
if !ok || module == "" {
at := strings.LastIndex(identity, ".")
if at <= 0 {
return "", false
}
return module, true
return identity[:at], true
}
+54
View File
@@ -0,0 +1,54 @@
package apply
import (
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// **A container's mesh names are part of what it is** (novox/hq 04-ISSUES/135).
//
// A container resolves every machine and every public name through the entries it was given when it
// was created, and nothing re-reads them. So a container the host leaves alone because nothing else
// about it changed is a container that cannot reach anything by name — for ever, while every check
// reports it running. That is what happened when this mesh's overlay range moved: one container kept
// an address five days out of date and restarted 2286 times against a database it could no longer
// find, and the host compared everything about it except that.
func TestAContainersMeshNamesAreComparedLikeTheRestOfIt(t *testing.T) {
was := &declaration.Container{
Name: "umami", Image: "ghcr.io/example/umami@sha256:" + zeros(64),
Hosts: []string{"novox.internal:10.42.0.1", "umami.novox.be:10.42.0.1"},
}
moved := &declaration.Container{
Name: was.Name, Image: was.Image,
Hosts: []string{"novox.internal:10.10.0.1", "umami.novox.be:10.10.0.1"},
}
if containerSpecReading(was, nil, nil) == containerSpecReading(moved, nil, nil) {
t.Fatal("a container whose mesh names moved compares equal, so it is never recreated")
}
// And the order they arrive in is not a change: the digest must not move for a reordering
// nobody made.
reordered := &declaration.Container{
Name: moved.Name, Image: moved.Image,
Hosts: []string{moved.Hosts[1], moved.Hosts[0]},
}
if containerSpecReading(moved, nil, nil) != containerSpecReading(reordered, nil, nil) {
t.Fatal("the same names in another order read as a different container")
}
// A container the mesh gives no names is unaffected, so nothing is recreated for a field it
// does not set.
plain := &declaration.Container{Name: "plex", Image: was.Image}
if containerSpecReading(plain, nil, nil) == containerSpecReading(was, nil, nil) {
return // different for other reasons, which is fine
}
}
func zeros(n int) string {
out := make([]byte, n)
for i := range out {
out[i] = '0'
}
return string(out)
}
+25 -1
View File
@@ -343,7 +343,7 @@ func TestAFailedStepGatesItsModuleAndNotTheMachine(t *testing.T) {
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"mesh-catalog.runtime.prepare","type":"container","name":"catalogue-prepare","image":"`+pinned+`","run-once":true},
{"id":"mesh-catalog.runtime-prepare","type":"container","name":"catalogue-prepare","image":"`+pinned+`","run-once":true},
{"id":"mesh-catalog.runtime","type":"container","name":"catalogue","image":"`+pinned+`"},
{"id":"gitea.server","type":"container","name":"forge","image":"`+pinned+`"}
]}`)
@@ -374,3 +374,27 @@ func TestAFailedStepGatesItsModuleAndNotTheMachine(t *testing.T) {
t.Errorf("the report does not say what was not attempted: %q", skipped)
}
}
func TestAResourcesOwnerIsReadToTheLastDot(t *testing.T) {
// **A module's name may contain a dot.** `novox.be` is one on this mesh, so reading a resource's
// owner to the first dot would make its resources belong to something called "novox" — and a gate
// would skip whatever else happened to start that way. A resource's own id never contains one,
// which is what makes the last dot the boundary.
for identity, want := range map[string]string{
"novox.be.server": "novox.be",
"mesh-catalog.runtime-prepare": "mesh-catalog",
"gitea.admin-bootstrap": "gitea",
} {
got, ours := moduleOf(identity)
if !ours || got != want {
t.Errorf("%q belongs to %q (%v), want %q", identity, got, ours, want)
}
}
// What the mesh declares in its own right belongs to no module: the foundation's resources carry
// no dot, and the adoption's are the mesh's.
for _, identity := range []string{"container-runtime", "store-ready", "adoption.guard", ".server"} {
if _, ours := moduleOf(identity); ours {
t.Errorf("%q was read as a module's", identity)
}
}
}
+14
View File
@@ -110,6 +110,20 @@ type Report struct {
// and the mesh's up in its place, and where the found configuration's original was kept.
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
// Outward is the links on this machine that face outside it — the ones carrying a default
// route (novox/hq ADR 0140). Every node reports it, adopted or converged, because a converged
// node's filter is written around it.
//
// **It replaces a list of addresses.** The filter used to block everything passing through the
// machine and then allow the machine's own containers back by naming the ranges they sit on.
// A range describes one machine and goes stale in silence; the link carrying the default route
// is read afresh on every report and does not change when a module is added or removed.
//
// Empty means this machine has no route off itself. The mesh then composes no filter for it and
// leaves the one it has, rather than writing a rule around a link with no name — a rule set
// that does not load is a machine filtering nothing while its unit reports success.
Outward []string `json:"outward,omitempty"`
// Rekey is this node taking a found tunnel's key as its overlay key after enrolment (novox/hq
// ADR 0105). Not an account of the machine: a report carrying one says nothing else.
Rekey *Rekey `json:"rekey,omitempty"`
+147
View File
@@ -0,0 +1,147 @@
// Package outward reads which of this machine's links face outside it (novox/hq ADR 0140).
//
// The filter the mesh derives constrains traffic arriving from outside the machine and says nothing
// about traffic that did not. To write that rule the mesh has to know which links "outside" arrives
// on, and that is a thing only the machine can say — so it says it, once per report, the way it
// already reports the kind of firewall it found and the tunnel it carried.
//
// **It replaces a list of addresses.** The filter used to allow the machine's own containers back
// through by naming the address ranges they sit on: two ranges fixed in the control plane's source
// and the rest typed by an operator. A range describes one machine and goes stale silently
// (novox/hq 04-ISSUES/137 and /141). A link that carries the default route is a fact the machine
// reads afresh every time, and it does not change when a module is added or removed.
//
// It reads the kernel's routing tables directly rather than asking a program. A module naming a
// program the machine does not have is how the mesh already reported success while doing nothing
// (novox/hq 04-ISSUES/136), and every machine has /proc.
package outward
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// ProcNet is where the kernel publishes its routing tables. A parameter so a test can hold a
// routing table without one.
const ProcNet = "/proc/net"
// Links are the interfaces carrying a default route, for both address families, sorted and without
// repeats.
//
// A machine may have more than one: a laptop with a cable and a radio has two, and both face
// outside. A machine with none — no route off itself — returns nothing, and the mesh refuses to
// compose a filter for it rather than writing a rule around a link with no name, which would be a
// rule set that does not load and a machine filtering nothing while its unit reports success.
func Links(procNet string) ([]string, error) {
if procNet == "" {
procNet = ProcNet
}
seen := map[string]bool{}
four, err := defaultsV4(filepath.Join(procNet, "route"))
if err != nil {
return nil, err
}
six, err := defaultsV6(filepath.Join(procNet, "ipv6_route"))
if err != nil {
return nil, err
}
for _, name := range append(four, six...) {
if name != "" && name != "lo" {
seen[name] = true
}
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
sort.Strings(out)
return out, nil
}
// defaultsV4 reads /proc/net/route, whose columns are
//
// Iface Destination Gateway Flags RefCnt Use Metric Mask ...
//
// with addresses in hexadecimal. A default route is destination zero with mask zero — the mask
// matters, because a route to the zero address with a real mask is not a default route.
func defaultsV4(path string) ([]string, error) {
lines, err := rows(path)
if err != nil {
return nil, err
}
var out []string
for _, fields := range lines {
if len(fields) < 8 {
continue
}
if isZeroHex(fields[1]) && isZeroHex(fields[7]) {
out = append(out, fields[0])
}
}
return out, nil
}
// defaultsV6 reads /proc/net/ipv6_route, whose columns are
//
// dest destprefix src srcprefix nexthop metric refcnt use flags iface
//
// A default route is the zero destination with a zero prefix length.
func defaultsV6(path string) ([]string, error) {
lines, err := rows(path)
if err != nil {
return nil, err
}
var out []string
for _, fields := range lines {
if len(fields) < 10 {
continue
}
if isZeroHex(fields[0]) && isZeroHex(fields[1]) {
out = append(out, fields[9])
}
}
return out, nil
}
// rows reads a routing table into fields per line, skipping a header and blank lines. A table that
// is not there is not an error: a machine without the second address family has no file for it,
// and that is not a machine that cannot be filtered.
func rows(path string) ([][]string, error) {
file, err := os.Open(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err)
}
defer file.Close()
var out [][]string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "Iface") {
continue
}
out = append(out, strings.Fields(line))
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err)
}
return out, nil
}
// isZeroHex is whether a hexadecimal field is all zeroes, whatever its width — the v4 table writes
// eight digits and the v6 table thirty-two, and a prefix length is two.
func isZeroHex(field string) bool {
if field == "" {
return false
}
return strings.Trim(strings.ToLower(field), "0") == ""
}
+120
View File
@@ -0,0 +1,120 @@
package outward
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// A routing table as the kernel writes it: a default route, a route to the zero address that is not
// one, and a route on the loopback. Only the default route's link faces outside.
const routeV4 = `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
enp9s0 00000000 01FEA8C0 0003 0 0 100 00000000 0 0 0
docker0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0
enp9s0 00000000 00000000 0001 0 0 100 00FFFFFF 0 0 0
lo 00000000 00000000 0003 0 0 0 00000000 0 0 0
`
const routeV6 = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 wlan0
fd0000000000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000000 00000000 00000001 enp9s0
`
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLinksAreTheOnesCarryingADefaultRoute(t *testing.T) {
dir := t.TempDir()
write(t, dir, "route", routeV4)
write(t, dir, "ipv6_route", routeV6)
got, err := Links(dir)
if err != nil {
t.Fatal(err)
}
// The cable from the v4 table and the radio from the v6 one. Not docker0, whose route is not a
// default; not the loopback, which faces nothing; and not the v6 route with a real prefix.
want := []string{"enp9s0", "wlan0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("outward links are %v, want %v", got, want)
}
}
// A route to the zero address with a real mask is not a default route. Trusting the destination
// alone would name every link with such a route as facing outside, and a filter that treats an
// internal bridge as outward constrains this machine's own guests — the fault ADR 0140 removes.
func TestAZeroDestinationWithAMaskIsNotADefaultRoute(t *testing.T) {
dir := t.TempDir()
write(t, dir, "route", `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
br-abc 00000000 00000000 0001 0 0 0 00FFFFFF 0 0 0
`)
got, err := Links(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("outward links are %v, want none", got)
}
}
// A machine with no route off itself says so, rather than guessing. The mesh refuses to compose a
// filter for it; a rule written around a link with no name does not load, and a rule set that does
// not load is a machine filtering nothing while its unit reports success.
func TestNoDefaultRouteIsNoLinks(t *testing.T) {
dir := t.TempDir()
write(t, dir, "route", "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n")
got, err := Links(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("outward links are %v, want none", got)
}
}
// A machine without the second address family has no file for it. That is not a machine that cannot
// be filtered, so a missing table is read as no routes rather than as a failure.
func TestAMissingTableIsNotAFailure(t *testing.T) {
dir := t.TempDir()
write(t, dir, "route", routeV4)
got, err := Links(dir)
if err != nil {
t.Fatalf("a missing v6 table should not fail: %v", err)
}
if !reflect.DeepEqual(got, []string{"enp9s0"}) {
t.Fatalf("outward links are %v, want [enp9s0]", got)
}
}
// The same link carrying a default route in both families is reported once.
func TestALinkIsReportedOnce(t *testing.T) {
dir := t.TempDir()
write(t, dir, "route", routeV4)
write(t, dir, "ipv6_route",
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 "+
"fe800000000000000000000000000001 00000400 00000001 00000000 00000003 enp9s0\n")
got, err := Links(dir)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, []string{"enp9s0"}) {
t.Fatalf("outward links are %v, want [enp9s0]", got)
}
}
// Against this machine's own routing table, so the parse is held to what the kernel actually writes
// and not only to a fixture written to agree with it.
func TestAgainstThisMachinesOwnTable(t *testing.T) {
got, err := Links("")
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Skip("this machine has no default route")
}
t.Logf("this machine's outward links: %v", got)
}