diff --git a/examples/route-proxy/main.go b/examples/route-proxy/main.go index 9b59643..78f52f7 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -97,10 +97,10 @@ func issuer() string { // to what it may serve. func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { return func(_ context.Context, host string) error { - if held.routed(host) { + if held.eligibleForACME(host) { 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 { mu sync.RWMutex 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{} for host, rules := range routes { kept := make([]rule, 0, len(rules)) @@ -192,6 +197,7 @@ func (t *table) set(routes map[string][]rule) { } t.mu.Lock() t.to = made + t.public = public 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 // 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 { t.mu.RLock() defer t.mu.RUnlock() @@ -282,7 +298,7 @@ func run() error { held := newTable() read := func() { - routes, err := routesFrom(path) + routes, public, err := routesFrom(path) if err != nil { // 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 @@ -290,7 +306,7 @@ func run() error { log.Printf("cannot read %s, keeping what is already served: %v", path, err) return } - held.set(routes) + held.set(routes, public) log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", ")) } read() @@ -512,18 +528,21 @@ func boolByte(b bool) byte { return 0 } -// routesFrom reads what the mesh wrote and turns it into host → the rules for that host. -func routesFrom(path string) (map[string][]rule, error) { +// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and +// 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) if err != nil { - return nil, err + return nil, nil, err } var said given if err := json.Unmarshal(raw, &said); err != nil { - return nil, err + return nil, nil, err } out := map[string][]rule{} + public := map[string]bool{} for _, c := range said.Given { name, _ := c.Values["name"].(string) if name == "" { @@ -531,6 +550,7 @@ func routesFrom(path string) (map[string][]rule, error) { continue } host := strings.ToLower(name) + public[host] = true made := rule{path: asPath(c.Values["path"])} 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) + + // 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. diff --git a/examples/route-proxy/policy_test.go b/examples/route-proxy/policy_test.go index db1dc0b..192af8d 100644 --- a/examples/route-proxy/policy_test.go +++ b/examples/route-proxy/policy_test.go @@ -61,12 +61,12 @@ func proxyFor(t *testing.T, routesJSON string) string { if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil { t.Fatal(err) } - routes, err := routesFrom(path) + routes, public, err := routesFrom(path) if err != nil { t.Fatal(err) } held := newTable() - held.set(routes) + held.set(routes, public) server := httptest.NewServer(handler(held)) t.Cleanup(server.Close) return server.URL @@ -157,7 +157,7 @@ func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) { if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } - routes, err := routesFrom(path) + routes, _, err := routesFrom(path) if err != nil { t.Fatal(err) } diff --git a/examples/route-proxy/routes_test.go b/examples/route-proxy/routes_test.go index c4d6ef2..b4d4bfa 100644 --- a/examples/route-proxy/routes_test.go +++ b/examples/route-proxy/routes_test.go @@ -28,6 +28,16 @@ func plain(routes map[string]string) map[string][]rule { 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. func targetOf(routes map[string][]rule, host string) string { 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 // mesh rather than from a naming convention the proxy has to know. 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}} ]}`)) 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 // only thing that works when there is no private network. 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}} ]}`)) 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. 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":"b","node":"n","at":"n.internal","values":{"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, ":") 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)) 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. func TestWithdrawingARouteStopsServingIt(t *testing.T) { held := newTable() - held.set(plain(map[string]string{ + initial := plain(map[string]string{ "going.example": "http://a.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 { 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. func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { 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 { 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. func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { 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) 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. func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { 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) if err := policy(context.Background(), "photos.example"); err != nil { t.Fatal(err) } - held.set(nil) + held.set(nil, 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 " + "once rather than what is served now")