The mesh may only move a port it actually publishes

The lab caught this: a module declaring a port and running no container
had its rule set opened on 20000 while its service sat on 9101. The
firewall reported success and blocked the thing it was told to admit,
which is the precise failure the filtering comment warns about, arrived
at from the other side.

Assignment was applied to every declared port. But a container's mapping
is the thing that translates, and where there is none the software binds
what it binds — the mesh choosing a number does not move the service, it
only makes the mesh wrong about where it is.

The declaration side already knew this: publishedOn rewrites container
ports and nothing else. Filtering did not, so the two disagreed about
the same fact. MachineSide is now the one derivation both follow.

It also fixes a second case nobody had hit yet: a mapping the manifest
wrote itself, like the mail system's 7080:80. That is passed through
untouched when composing, so assigning it a machine port would have
opened a rule on a port the container does not publish. Either side of
such a mapping now names it, and the host side is the answer — a module
may read `listens` as what its software binds or as what the machine
exposes, and both readings want the same number.

Recorded either way, assigned or not: the map means where this module's
port is on this machine, and every reader needs that answer regardless
of who chose it.

Tests bite — making it always assignable reproduces the lab failure.
This commit is contained in:
2026-09-01 19:31:05 +02:00
parent 41f7c51032
commit c67f836185
3 changed files with 126 additions and 6 deletions
+50
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -711,3 +712,52 @@ func (m Manifest) OffersAt(scope string) []string {
sort.Strings(out)
return out
}
// MachineSide says where a module's declared port reaches this machine, and whether the mesh is
// free to choose it.
//
// **The mesh may only move a port it actually publishes** (novox/hq ADR 0038). A container's
// mapping is the thing that translates, so where there is one the mesh can put the machine side
// anywhere it likes. Where there is not, the software binds what it binds: assigning a port then
// does not move the service, it just opens the wrong number in the rule set and leaves the real
// one shut — a firewall that reports success and blocks the thing it was asked to admit.
//
// Three cases, and only the first belongs to the mesh:
//
// - a container publishes it in short form — the mesh chooses
// - a container publishes it as host:container — the manifest already chose
// - nothing publishes it — whatever binds it, binds it
//
// Either side of a long mapping counts as naming it, and the host side is what comes back. A
// module may reasonably read `listens` as the port its software uses or as the port the machine
// exposes, and both readings have the same right answer here.
func (m Manifest) MachineSide(port int) (at int, mayAssign bool) {
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
listed, ok := r["ports"].([]any)
if !ok {
continue
}
for _, entry := range listed {
written := strings.TrimSpace(fmt.Sprint(entry))
host, inside, long := strings.Cut(written, ":")
if !long {
if n, err := strconv.Atoi(written); err == nil && n == port {
return port, true
}
continue
}
outer, err := strconv.Atoi(strings.TrimSpace(host))
if err != nil {
continue
}
inner, err := strconv.Atoi(strings.TrimSpace(inside))
if err == nil && (outer == port || inner == port) {
return outer, false
}
}
}
return port, false
}