Compare commits

...
Author SHA1 Message Date
mesh-admin 07c07902ff Merge pull request 'Anything on this machine may call anything on this machine' (#143) from fix/local-calls-are-not-filtered into main 2026-09-29 11:30:34 +00:00
jschoubben 864cdea4c6 Anything on this machine may call anything on this machine
Local is not a boundary this mesh draws. A service here is callable by everything
else here, whatever form either takes — a package with a unit, a binary, a
container. Whether a caller sits in a container was never meant to change the
answer, and the only reason it did was that this chain asked about addresses: a
caller on the machine carries the machine's address, a caller in one of its
containers carries a bridge address, and a rule naming the former silently refused
the latter.

One rule for every service here, replacing the line-per-port added an hour ago,
which only ever covered the ports somebody remembered to think about. The three
reaches are now three lines: on this machine, over the private network, from
anywhere.

The tests assert per chain body, because the forward chain carries the same line in
the same words and an assertion on the whole file passed with the input chain's copy
deleted — which is what ADR 0137's own tests say to do and this file was not doing.
2026-09-29 13:30:32 +02:00
mesh-admin 6e810907b2 Merge pull request 'This machine's own guests are on the private network' (#142) from fix/this-machines-own-guests-are-on-the-private-network into main 2026-09-29 10:30:32 +00:00
jschoubben 2b20a12c4a This machine's own guests are on the private network
A port declared from the mesh admitted the machines' own addresses on the private
network. A container reaching a port on the machine it runs on comes from a bridge,
matching none of them — and where the container runtime routes directly, that packet
is delivered to this machine rather than forwarded, so the forward chain's allowance
never saw it either.

ADR 0100 requires this to work: the store is reachable 'from a container on the node
itself'. It was, through a rule the predecessor left, which allowed the private
ranges wholesale. Converging the machine replaced that with the four overlay
addresses and closed it.

Measured, and it was an outage: every module reaching another by its machine's own
name timed out for eleven hours while the mesh reported the machine healthy. A web
application logged 'connection to server at novox.internal (10.10.0.1), port 6852
failed: timeout expired' throughout.

Asked for by the link it arrives on, for the reason the forward chain no longer
names an address: a range describes one machine and goes stale in silence. A port
open to everything needs no such line.
2026-09-29 12:30:15 +02:00
mesh-admin 9c83dacfce Merge pull request 'A route that names an endpoint still carries that endpoint's port' (#141) from fix/an-endpoint-named-by-a-route-still-carries-its-port into main 2026-09-29 09:55:45 +00:00
jschoubben 64ba053f3b A route that names an endpoint still carries that endpoint's port
Everything downstream reads the port: the provider is told where to reach the
consumer, and the redirection that turns a declared port into the number the
machine published is keyed on it. A route naming only its endpoint left the proxy
with no port at all, and a proxy with no port has nothing to dial.

Caught after the catalogue had already been changed to name endpoints and before
the mesh picked those manifests up, which is the only reason nothing broke: every
module's manifest is behind its source right now, so the plan still renders from
the old shape.

The declared port, not the machine one — the redirection happens later and is keyed
on the declared number, so filling in the machine port here would be redirected
twice or not at all. A route that repeats a port keeps it.
2026-09-29 11:55:28 +02:00
mesh-admin 96416bd8a7 Merge pull request 'Run the real catalogue through the real manifest gate' (#140) from feat/an-assignment-configures-an-endpoint into main 2026-09-29 09:49:29 +00:00
mesh-admin aaad02fd38 Merge pull request 'An assignment configures an endpoint as one thing' (#139) from feat/an-assignment-configures-an-endpoint into main 2026-09-29 09:25:55 +00:00
4 changed files with 192 additions and 0 deletions
+31
View File
@@ -1102,6 +1102,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
if err != nil {
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
}
portOfEndpoint(values, endpointPorts(m))
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
}
@@ -1124,6 +1125,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
if err != nil {
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
}
portOfEndpoint(values, endpointPorts(m))
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
}
@@ -1837,3 +1839,32 @@ func endpointPorts(m Manifest) map[string]int {
}
return out
}
// portOfEndpoint fills in the port of the endpoint a contribution names, in place.
//
// **A contribution that names an endpoint must still carry that endpoint's port**, because everything
// downstream reads the port: the provider is told where to reach the consumer, and the machine-side
// redirection that turns a declared port into the number the machine published is keyed on it
// (atMachinePort). A route that named only its endpoint left the proxy with no port at all, and a
// proxy with no port has nothing to dial.
//
// Found before it shipped and after the catalogue had already been changed to name endpoints — the
// manifests were merged and the mesh had not yet picked them up, so nothing was broken yet. The
// declared port, not the machine one: the redirection happens later and is keyed on the declared
// number, so filling in the machine port here would be redirected a second time or not at all.
func portOfEndpoint(values map[string]any, ports map[string]int) {
if values == nil {
return
}
if _, already := values["port"]; already {
// A route that says both is its own answer; the older shape repeated the port and is still read.
return
}
name, ok := values[RouteEndpoint].(string)
if !ok {
return
}
if port, found := ports[strings.TrimSpace(name)]; found {
values["port"] = port
}
}
+58
View File
@@ -145,3 +145,61 @@ func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
t.Fatalf("a module with no route was refused: %v", got)
}
}
// **A route that names an endpoint still carries that endpoint's port.**
//
// Everything downstream reads the port: the provider is told where to reach the consumer, and the
// redirection that turns a declared port into the number the machine published is keyed on it. A route
// naming only its endpoint left the proxy with no port, and a proxy with no port has nothing to dial.
//
// Caught after the catalogue had already been changed to name endpoints, and before the mesh picked
// those manifests up — which is the only reason nothing broke.
func TestARouteNamingAnEndpointStillCarriesItsPort(t *testing.T) {
m := aMediaServer()
r := Resolution{Node: "anchor", Modules: []Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
given, err := r.contributions(nil, nil, nil)
if err != nil {
t.Fatal(err)
}
var saw bool
for _, c := range given["route"] {
saw = true
port, ok := asPort(c.Values["port"])
if !ok {
t.Fatalf("the route carries no port, so the proxy has nothing to dial: %v", c.Values)
}
if port != 80 {
t.Fatalf("the route carries port %d, want the web endpoint's 80", port)
}
}
if !saw {
t.Fatal("the module contributed no route")
}
}
// And the declared port, not the machine one: the redirection to where the machine published it
// happens later and is keyed on the declared number, so filling the machine port in here would be
// redirected twice or not at all.
func TestTheEndpointsDeclaredPortIsFilledInNotTheMachineOne(t *testing.T) {
m := aMediaServer()
values := map[string]any{RouteEndpoint: "web", "label": "media"}
portOfEndpoint(values, endpointPorts(m))
if got, _ := asPort(values["port"]); got != 80 {
t.Fatalf("filled in port %d, want the declared 80", got)
}
// Then the ordinary redirection puts it where the machine published it.
moved := atMachinePort(values, m.Module, map[string]map[int]int{"media": {80: 20009}})
if got, _ := asPort(moved["port"]); got != 20009 {
t.Fatalf("after redirection the port is %d, want the machine's 20009", got)
}
}
// A route that repeats a port keeps it, because that is the older shape and still read.
func TestARouteThatRepeatsItsPortKeepsIt(t *testing.T) {
values := map[string]any{RouteEndpoint: "web", "port": 8080}
portOfEndpoint(values, map[string]int{"web": 80})
if got, _ := asPort(values["port"]); got != 8080 {
t.Fatalf("the port it stated was overwritten with %d", got)
}
}
+20
View File
@@ -265,6 +265,26 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
b.WriteString("\t\tct state established,related accept\n")
b.WriteString("\t\tct state invalid drop\n")
b.WriteString("\t\tiif lo accept\n")
// **Anything on this machine may call anything on this machine.**
//
// Local is not a boundary this mesh draws. A service running here is callable by everything else
// running here, whatever form either takes — a package with a unit, a binary, a container. Whether
// a caller sits in a container was never meant to change the answer, and the only reason it did was
// that this chain asked about addresses: a caller on the machine carries the machine's address, a
// caller in one of its containers carries a bridge address, and a rule naming the former silently
// refused the latter.
//
// Measured: a module reaching its database on this machine's own name timed out for eleven hours
// while the machine itself could reach it, and the mesh called the machine healthy throughout
// (novox/hq 04-ISSUES/145).
//
// Asked by the link it arrives on rather than the address it comes from: anything that did not
// arrive from outside this machine, and did not arrive over the private network, is this machine's
// own. One rule for every service here, in place of a line per port that only ever covered the
// ports somebody remembered to think about.
if inward != "" {
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
}
b.WriteString("\t\ticmp type echo-request accept\n")
b.WriteString("\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n")
+83
View File
@@ -804,3 +804,86 @@ func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft)
}
}
// chainBody is one chain's own lines, so an assertion cannot be satisfied by an identical line in
// another chain.
//
// **Written because that happened.** The rule letting this machine's own callers through appears in the
// input chain and, in the same words, in the forward chain. A test asserting on the whole rendered file
// passed with the input chain's copy deleted — it was reading the forward chain's. ADR 0137's own tests
// say to assert per chain body for exactly this reason, and this file was not doing it.
func chainBody(t *testing.T, nft, chain string) string {
t.Helper()
open := "\tchain " + chain + " {"
i := strings.Index(nft, open)
if i < 0 {
t.Fatalf("no chain %q in:\n%s", chain, nft)
}
rest := nft[i+len(open):]
j := strings.Index(rest, "\n\t}")
if j < 0 {
t.Fatalf("chain %q does not close in:\n%s", chain, nft)
}
return rest[:j]
}
// **Anything on this machine may call anything on this machine.**
//
// Local is not a boundary this mesh draws, and whether a caller sits in a container was never meant to
// change the answer. It did, because the chain asked about addresses: a caller on the machine carries
// the machine's address and a caller in one of its containers carries a bridge address, so a rule
// naming the machines' own addresses silently refused every container on them.
//
// Measured: a module reaching its database on its own machine's name timed out for eleven hours while
// the machine itself could reach it (novox/hq 04-ISSUES/145).
func TestAnythingOnThisMachineMayCallAnythingOnIt(t *testing.T) {
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
{Module: "private", Listens: []Listening{{Port: 9999, From: FromMachine}}},
}}, nil), []string{"10.10.0.1", "10.10.0.2"}, false, nil, []string{"eth0"}, "mesh0")
// In the INPUT chain, which is where a call to a service on this machine arrives. The forward
// chain carries the same line in the same words, so asserting on the whole file proves nothing.
input := chainBody(t, nft, "input")
if !strings.Contains(input, `iifname != { "eth0", "mesh0" } accept`) {
t.Fatalf("a caller on this machine cannot reach a service on it:\n%s", input)
}
// One rule, for every service here — not a line per port that only covers the ports somebody
// remembered to think about.
if strings.Contains(input, `iifname != { "eth0", "mesh0" } tcp dport 5432`) {
t.Fatalf("the local allowance is still written per port:\n%s", input)
}
// And the private network still reaches what is exposed to it, which is a different question.
if !strings.Contains(input, "ip saddr { 10.10.0.1, 10.10.0.2 } tcp dport 5432 accept") {
t.Fatalf("the private network no longer reaches a service exposed to it:\n%s", input)
}
}
// The three reaches, as three lines. This is the whole of what the filter says about who may call what.
func TestTheThreeReachesAreThreeLines(t *testing.T) {
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
{Module: "internal-only", Listens: []Listening{{Port: 5432, From: FromMesh}}},
{Module: "public", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
input := chainBody(t, nft, "input")
for what, want := range map[string]string{
"on this machine": `iifname != { "eth0", "mesh0" } accept`,
"over the private network": "ip saddr { 10.10.0.1 } tcp dport 5432 accept",
"from anywhere": "tcp dport 443 accept",
} {
if !strings.Contains(input, want) {
t.Fatalf("a caller %s cannot reach what is exposed to it (%q):\n%s", what, want, input)
}
}
}
// A port open to everything needs no such line — it is already open to a guest.
func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
if strings.Count(nft, `iifname != { "eth0", "mesh0" } tcp dport 443`) != 0 {
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
}
}