route-proxy serves a route's internal-name alias, never certifies it

A route now consumed with two hosts when the mesh composed both — the
same host under internal-name reaches the same rule as its public name,
restoring the convenience a predecessor proxy gave for reaching a service
over the VPN without a public TLS round trip (the field composeName now
writes, feat/route-carries-internal-alias — this branch depends on that
one landing for internal-name to ever be populated; builds and tests
clean without it, just serves nothing extra).

Never certified: onlyWhatTheMeshSaid used routed(), which answered yes
for any host in the table regardless of how it got there. A new
eligibleForACME() checks a parallel 'public' set instead — every host
reached through a route's own name, never one reached only through its
internal-name — so an internal alias is proxied but never given its own
failing ACME order. routed() is unchanged and still used for the 404
message, which legitimately wants 'is this host served at all.'
This commit is contained in:
2026-09-25 17:24:13 +02:00
parent 752abaa81d
commit 6da55bdfea
3 changed files with 127 additions and 24 deletions
+40 -10
View File
@@ -97,10 +97,10 @@ func issuer() string {
// to what it may serve. // to what it may serve.
func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy {
return func(_ context.Context, host string) error { return func(_ context.Context, host string) error {
if held.routed(host) { if held.eligibleForACME(host) {
return nil return nil
} }
return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host) return fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", host)
} }
} }
@@ -163,9 +163,14 @@ type rule struct {
type table struct { type table struct {
mu sync.RWMutex mu sync.RWMutex
to map[string][]rule to map[string][]rule
// public is which routed hosts are eligible for a real certificate — every host reached as a
// route's own `name`, never one reached only as its `internal-name`. A private alias can never
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
public map[string]bool
} }
func (t *table) set(routes map[string][]rule) { func (t *table) set(routes map[string][]rule, public map[string]bool) {
made := map[string][]rule{} made := map[string][]rule{}
for host, rules := range routes { for host, rules := range routes {
kept := make([]rule, 0, len(rules)) kept := make([]rule, 0, len(rules))
@@ -192,6 +197,7 @@ func (t *table) set(routes map[string][]rule) {
} }
t.mu.Lock() t.mu.Lock()
t.to = made t.to = made
t.public = public
t.mu.Unlock() t.mu.Unlock()
} }
@@ -234,6 +240,16 @@ func (t *table) find(host, path string) (rule, bool) {
// //
// Separate from find because certificate issuance is a question about the *name*: a host whose only // Separate from find because certificate issuance is a question about the *name*: a host whose only
// rules are path-scoped is still a name this proxy answers to, and still needs a certificate. // rules are path-scoped is still a name this proxy answers to, and still needs a certificate.
// eligibleForACME says whether this proxy may ask a certificate authority for this name — every
// host reached as a route's own public `name`, never one reached only as its `internal-name`
// alias, which no public CA can ever validate.
func (t *table) eligibleForACME(host string) bool {
t.mu.RLock()
defer t.mu.RUnlock()
bare := bareHost(host)
return len(t.to[bare]) > 0 && t.public[bare]
}
func (t *table) routed(host string) bool { func (t *table) routed(host string) bool {
t.mu.RLock() t.mu.RLock()
defer t.mu.RUnlock() defer t.mu.RUnlock()
@@ -282,7 +298,7 @@ func run() error {
held := newTable() held := newTable()
read := func() { read := func() {
routes, err := routesFrom(path) routes, public, err := routesFrom(path)
if err != nil { if err != nil {
// Kept serving what it had. A file being rewritten is momentarily unreadable, and // Kept serving what it had. A file being rewritten is momentarily unreadable, and
// dropping every route because one read landed mid-write would turn an ordinary // dropping every route because one read landed mid-write would turn an ordinary
@@ -290,7 +306,7 @@ func run() error {
log.Printf("cannot read %s, keeping what is already served: %v", path, err) log.Printf("cannot read %s, keeping what is already served: %v", path, err)
return return
} }
held.set(routes) held.set(routes, public)
log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", ")) log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", "))
} }
read() read()
@@ -512,18 +528,21 @@ func boolByte(b bool) byte {
return 0 return 0
} }
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host. // routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
func routesFrom(path string) (map[string][]rule, error) { // which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
// only through `internal-name` never appears there.
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
var said given var said given
if err := json.Unmarshal(raw, &said); err != nil { if err := json.Unmarshal(raw, &said); err != nil {
return nil, err return nil, nil, err
} }
out := map[string][]rule{} out := map[string][]rule{}
public := map[string]bool{}
for _, c := range said.Given { for _, c := range said.Given {
name, _ := c.Values["name"].(string) name, _ := c.Values["name"].(string)
if name == "" { if name == "" {
@@ -531,6 +550,7 @@ func routesFrom(path string) (map[string][]rule, error) {
continue continue
} }
host := strings.ToLower(name) host := strings.ToLower(name)
public[host] = true
made := rule{path: asPath(c.Values["path"])} made := rule{path: asPath(c.Values["path"])}
if p, ok := asWhole(c.Values["priority"]); ok { if p, ok := asWhole(c.Values["priority"]); ok {
@@ -579,8 +599,18 @@ func routesFrom(path string) (map[string][]rule, error) {
} }
out[host] = append(out[host], made) out[host] = append(out[host], made)
// The internal-network alias, the same rule under a second host — a predecessor proxy
// answered both for one route, as a convenience (reaching a service over the VPN without a
// public TLS round trip), not as an access boundary; composing it here restores exactly
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
// already has.
if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" {
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
} }
return out, nil }
return out, public, nil
} }
// asWhole is any whole number the mesh wrote, whatever its magnitude. // asWhole is any whole number the mesh wrote, whatever its magnitude.
+3 -3
View File
@@ -61,12 +61,12 @@ func proxyFor(t *testing.T, routesJSON string) string {
if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil { if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
routes, err := routesFrom(path) routes, public, err := routesFrom(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
held := newTable() held := newTable()
held.set(routes) held.set(routes, public)
server := httptest.NewServer(handler(held)) server := httptest.NewServer(handler(held))
t.Cleanup(server.Close) t.Cleanup(server.Close)
return server.URL return server.URL
@@ -157,7 +157,7 @@ func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil { if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
routes, err := routesFrom(path) routes, _, err := routesFrom(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+84 -11
View File
@@ -28,6 +28,16 @@ func plain(routes map[string]string) map[string][]rule {
return out return out
} }
// allPublic is every host in a routes map, ACME-eligible — the ordinary case for a test with no
// internal-name alias of its own to distinguish.
func allPublic(routes map[string][]rule) map[string]bool {
out := map[string]bool{}
for host := range routes {
out[host] = true
}
return out
}
// targetOf is where a host's first matching rule sends a request. // targetOf is where a host's first matching rule sends a request.
func targetOf(routes map[string][]rule, host string) string { func targetOf(routes map[string][]rule, host string) string {
if rules := routes[host]; len(rules) > 0 { if rules := routes[host]; len(rules) > 0 {
@@ -39,7 +49,7 @@ func targetOf(routes map[string][]rule, host string) string {
// A route is a grant: the consumer supplies a target, and where that machine is comes from the // A route is a grant: the consumer supplies a target, and where that machine is comes from the
// mesh rather than from a naming convention the proxy has to know. // mesh rather than from a naming convention the proxy has to know.
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
routes, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[ routes, _, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[
{"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}} {"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}}
]}`)) ]}`))
if err != nil { if err != nil {
@@ -52,10 +62,49 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
} }
} }
// A route with an internal-name alias is reachable under both hostnames, pointed at the same
// target — the same convenience a predecessor proxy gave for reaching a service over the VPN
// without a public TLS round trip.
func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) {
routes, public, err := routesFrom(write(t, `{"given":[
{"from":"app","node":"anchor","at":"anchor.internal",
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
if targetOf(routes, "app.example") != "http://anchor.internal:8080" {
t.Fatalf("the public name does not point at the consumer: %v", routes)
}
if targetOf(routes, "app.anchor.internal") != "http://anchor.internal:8080" {
t.Fatalf("the internal alias does not point at the same consumer: %v", routes)
}
if !public["app.example"] {
t.Errorf("the public name is not eligible for a certificate: %v", public)
}
if public["app.anchor.internal"] {
t.Errorf("the internal alias is eligible for a certificate no public CA could ever issue: %v",
public)
}
}
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
routes, _, err := routesFrom(write(t, `{"given":[
{"from":"app","node":"anchor","values":{"name":"app.example","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
if len(routes) != 1 {
t.Fatalf("a route with no internal-name grew a second host: %v", routes)
}
}
// A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the // A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the
// only thing that works when there is no private network. // only thing that works when there is no private network.
func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) { func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[ routes, _, err := routesFrom(write(t, `{"given":[
{"from":"app","node":"anchor","values":{"name":"app.example","port":9000}} {"from":"app","node":"anchor","values":{"name":"app.example","port":9000}}
]}`)) ]}`))
if err != nil { if err != nil {
@@ -68,7 +117,7 @@ func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
// Skipped rather than served wrongly. A route with no port would proxy to :0. // Skipped rather than served wrongly. A route with no port would proxy to :0.
func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[ routes, _, err := routesFrom(write(t, `{"given":[
{"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}}, {"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}},
{"from":"b","node":"n","at":"n.internal","values":{"port":8080}}, {"from":"b","node":"n","at":"n.internal","values":{"port":8080}},
{"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}} {"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}}
@@ -92,7 +141,7 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
host, port, _ := strings.Cut(target, ":") host, port, _ := strings.Cut(target, ":")
held := newTable() held := newTable()
held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port})) held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}), allPublic(plain(map[string]string{"app.example": "http://" + host + ":" + port})))
proxy := httptest.NewServer(handler(held)) proxy := httptest.NewServer(handler(held))
defer proxy.Close() defer proxy.Close()
@@ -137,11 +186,12 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
// nothing fails more visibly than a stale grant, which is exactly why it must not survive. // nothing fails more visibly than a stale grant, which is exactly why it must not survive.
func TestWithdrawingARouteStopsServingIt(t *testing.T) { func TestWithdrawingARouteStopsServingIt(t *testing.T) {
held := newTable() held := newTable()
held.set(plain(map[string]string{ initial := plain(map[string]string{
"going.example": "http://a.internal:80", "going.example": "http://a.internal:80",
"staying.example": "http://b.internal:80", "staying.example": "http://b.internal:80",
})) })
held.set(plain(map[string]string{"staying.example": "http://b.internal:80"})) held.set(initial, allPublic(initial))
held.set(plain(map[string]string{"staying.example": "http://b.internal:80"}), allPublic(plain(map[string]string{"staying.example": "http://b.internal:80"})))
if _, still := held.find("going.example", "/"); still { if _, still := held.find("going.example", "/"); still {
t.Fatal("a route whose module was unassigned is still served") t.Fatal("a route whose module was unassigned is still served")
@@ -154,7 +204,7 @@ func TestWithdrawingARouteStopsServingIt(t *testing.T) {
// A Host header carries a port and the name does not. // A Host header carries a port and the name does not.
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
held := newTable() held := newTable()
held.set(plain(map[string]string{"app.example": "http://a.internal:8080"})) held.set(plain(map[string]string{"app.example": "http://a.internal:8080"}), allPublic(plain(map[string]string{"app.example": "http://a.internal:8080"})))
if _, found := held.find("app.example:8080", "/"); !found { if _, found := held.find("app.example:8080", "/"); !found {
t.Fatal("a request to app.example:8080 did not find the route for app.example") t.Fatal("a request to app.example:8080 did not find the route for app.example")
} }
@@ -185,7 +235,7 @@ func TestTheIssuerIsStagingUnlessNamed(t *testing.T) {
// rate limit — and the proxy would look healthy throughout. // rate limit — and the proxy would look healthy throughout.
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
held := newTable() held := newTable()
held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})) held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})))
policy := onlyWhatTheMeshSaid(held) policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil { if err := policy(context.Background(), "photos.example"); err != nil {
@@ -198,16 +248,39 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
} }
} }
// A certificate is asked for on a route's public name, never on its internal-network alias — no
// public CA can validate a private name, and asking anyway would only spend the account's rate
// limit on an order that can never succeed.
func TestNoCertificateIsAskedForOnAnInternalAlias(t *testing.T) {
routes, public, err := routesFrom(write(t, `{"given":[
{"from":"app","node":"anchor","at":"anchor.internal",
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
held := newTable()
held.set(routes, public)
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "app.example"); err != nil {
t.Errorf("the route's public name was refused a certificate: %v", err)
}
if err := policy(context.Background(), "app.anchor.internal"); err == nil {
t.Error("a certificate was ordered for the internal alias, which no public CA can validate")
}
}
// A route withdrawn stops being certifiable, without the proxy restarting. // A route withdrawn stops being certifiable, without the proxy restarting.
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
held := newTable() held := newTable()
held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})) held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})))
policy := onlyWhatTheMeshSaid(held) policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil { if err := policy(context.Background(), "photos.example"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
held.set(nil) held.set(nil, nil)
if err := policy(context.Background(), "photos.example"); err == nil { if err := policy(context.Background(), "photos.example"); err == nil {
t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " + t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " +
"once rather than what is served now") "once rather than what is served now")