a route composes its internal-network alias too, not only its public name

Every cutover done on novox tonight (drive, files, files-api, git,
keycloak, umami) dropped the <label>.<node>.internal alias HAL always
paired with the public hostname — found only when the operator tested it
by hand. Not a security boundary (a predecessor proxy served both as a
convenience, reaching a service over the VPN without a public TLS round
trip, not as access control), so restoring it is composing the same
convenience the same way the public name already is: <label> joined to
the node's own private address (r.At), independently of whether a public
domain exists to join the other half to.

composeName's signature changes (publicDomain, internalDomain) but its
shape does not — additive, label-gated, apex-aware, exactly mirroring the
public half it already did. A contribution the mesh writes both names
into is the entire fix; route-adapter and route-proxy pick up internal-
name whenever they're updated to serve it, not before, so this alone
changes nothing about what is live on any node yet.
This commit is contained in:
2026-09-25 16:51:38 +02:00
parent 506426cf94
commit f996a6707e
2 changed files with 101 additions and 16 deletions
+66
View File
@@ -132,6 +132,72 @@ func TestALabelWithNoPublicDomainComposesNothing(t *testing.T) {
}
}
// withPrivateAddress is a workstation on the private network, at the given internal name — the
// same fact a route's own consumers already receive as `${bound:...:at}`.
func withPrivateAddress(at string) Node {
n := workstation()
n.At = at
return n
}
func TestALabelComposesWithTheNodesPrivateAddressToo(t *testing.T) {
// A predecessor proxy answered a route on both a public and a private-network hostname for the
// same convenience the mesh restores here: reaching a service over the VPN without a public TLS
// round trip. Composed independently of the public name, from the node's own `At`.
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["internal-name"] != "git.anchor.internal" {
t.Fatalf("the label did not compose with the private address: %v", given[0].Values)
}
}
func TestThePublicAndInternalNamesComposeIndependently(t *testing.T) {
// A node with both a public domain and a private address gets both names from one label; a
// node with only one of the two gets only the matching one — neither composition depends on
// the other being possible.
both := withPrivateAddress("anchor.internal")
both.PublicDomain = "example.tld"
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, both, World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["name"] != "git.example.tld" {
t.Fatalf("the public name did not compose alongside the internal one: %v", given[0].Values)
}
if given[0].Values["internal-name"] != "git.anchor.internal" {
t.Fatalf("the internal name did not compose alongside the public one: %v", given[0].Values)
}
publicOnly, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
givenPublicOnly := received(t, mustDeclare(t, publicOnly))
if _, has := givenPublicOnly[0].Values["internal-name"]; has {
t.Fatalf("an internal name was composed with no private address to compose it from: %v",
givenPublicOnly[0].Values)
}
}
func TestTheApexLabelComposesToTheBarePrivateAddress(t *testing.T) {
got, err := Resolve(shelf(proxy(), labelled("board", "@", 4000)),
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["internal-name"] != "anchor.internal" {
t.Fatalf("the apex label did not compose to the bare private address: %v", given[0].Values)
}
}
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
// novox/hq ADR 0066 propagate: a granted route name is published into internal resolution,
// mapped to the node that serves it, alongside the `<node>.internal` names — so every