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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user