Files
mesh-controller/examples/route-proxy/routes_test.go
T
jochen 008ce39ec0 route-proxy: a route carries the policy applied to a request
Implements novox/hq ADR 0108, closing issue 116. The proxy's request path was a host lookup
and a forward, so it applied nothing — while the ingress it replaces relies on four things it
had none of.

Path scoping came first because it is a prerequisite, not a sibling. The table mapped a host
to one target, so a host could not be routed two ways, and the refusal this issue turns on
matches a path on a host already routed to a workload. No amount of authentication or source
filtering would have made it expressible. The table is now host to an ordered list of rules,
matched on path prefix.

The order is total, not just by priority. Sorting on priority alone leaves rules that share
one in whatever order the map produced, so the same declaration would serve differently
between restarts — a fault that works, and works differently each time, which is the hardest
kind to believe when reported. Within a priority the longer path wins, which is also the
intuitive reading.

auth names a secret and never holds one. A declaration carrying a credential is refused
whole rather than served unprotected, so the option ADR 0108 rejected cannot return by
accident. A secret that cannot be read makes the route refuse and say so, rather than serve
the workload unprotected — a gate that cannot check is not a gate that opens, and the
alternative turns a missing file into a silently public admin surface.

Authentication costs one bcrypt comparison on every path including an unknown user, so an
unknown user is not measurably faster than a known one with a wrong password. That difference
is a way to enumerate a route's users from outside it.

Redirects keep the request's own path and query, or canonicalising one name onto another
would land every deep link on the front page and raise no error doing it.

Eleven tests, four of them for the capabilities and two for the failure modes that rot
quietly: the credential-in-a-declaration refusal, and the unreadable secret failing closed.
Nothing else breaks if those stop working, so nothing else would report it.

No new dependency: bcrypt comes from the x/crypto module already required.
2026-09-25 14:00:57 +02:00

216 lines
7.8 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "routes.json")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// plain is the table an ordinary set of routes makes: one host, one target, no policy.
func plain(routes map[string]string) map[string][]rule {
out := map[string][]rule{}
for host, target := range routes {
out[host] = []rule{{target: target}}
}
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 {
return rules[0].target
}
return ""
}
// 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":[
{"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
// spelling in the manifest answers half the requests made to it.
if targetOf(routes, "app.example") != "http://laptop.internal:8080" {
t.Fatalf("the route does not point at the consumer: %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":[
{"from":"app","node":"anchor","values":{"name":"app.example","port":9000}}
]}`))
if err != nil {
t.Fatal(err)
}
if targetOf(routes, "app.example") != "http://127.0.0.1:9000" {
t.Fatalf("a workload on this machine was not reachable: %v", routes)
}
}
// 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":[
{"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}}
]}`))
if err != nil {
t.Fatal(err)
}
if len(routes) != 1 || targetOf(routes, "fine.example") == "" {
t.Fatalf("an unusable contribution was served: %v", routes)
}
}
// End to end through the proxy itself: a request for the name reaches the workload, and a name
// nobody asked for is refused in a way that says what IS served.
func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("the workload"))
}))
defer workload.Close()
target := strings.TrimPrefix(workload.URL, "http://")
host, port, _ := strings.Cut(target, ":")
held := newTable()
held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}))
proxy := httptest.NewServer(handler(held))
defer proxy.Close()
asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil)
if err != nil {
t.Fatal(err)
}
asked.Host = "app.example"
answer, err := http.DefaultClient.Do(asked)
if err != nil {
t.Fatal(err)
}
defer answer.Body.Close()
if answer.StatusCode != http.StatusOK {
t.Fatalf("a request for a served name got %d", answer.StatusCode)
}
// And a name that is not served says which are — a route withdrawn and a name that never
// existed are different things, and a bare 404 makes an operator go and read the mesh.
other, _ := http.NewRequest(http.MethodGet, proxy.URL, nil)
other.Host = "nobody.example"
refused, err := http.DefaultClient.Do(other)
if err != nil {
t.Fatal(err)
}
defer refused.Body.Close()
if refused.StatusCode != http.StatusNotFound {
t.Fatalf("a name nobody asked for got %d", refused.StatusCode)
}
body := make([]byte, 256)
n, _ := refused.Body.Read(body)
if !strings.Contains(string(body[:n]), "app.example") {
t.Fatalf("the refusal does not say what is served: %s", body[:n])
}
}
// The file is the whole truth about who has a route, so the table replaces rather than merges.
//
// Merging would keep serving a name whose module was unassigned — the stale-route fault
// 08-connectivity lists as open, reintroduced one level down. A stale public name pointing at
// 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{
"going.example": "http://a.internal:80",
"staying.example": "http://b.internal:80",
}))
held.set(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")
}
if _, kept := held.find("staying.example", "/"); !kept {
t.Fatal("withdrawing one route took another with it")
}
}
// 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"}))
if _, found := held.find("app.example:8080", "/"); !found {
t.Fatal("a request to app.example:8080 did not find the route for app.example")
}
}
// Defends novox/hq 04-ISSUES/004: issuance targets staging unless something says otherwise.
//
// The failure this guards is not a broken proxy. It is a working one that quietly spends a
// production quota which does not replenish for a week, on exactly the work most likely to
// iterate.
func TestTheIssuerIsStagingUnlessNamed(t *testing.T) {
t.Setenv("ACME_DIRECTORY", "")
if got := issuer(); !strings.Contains(got, "staging") {
t.Fatalf("with nothing set the issuer is %q, and a default that spends production quota "+
"is a default nobody chose", got)
}
t.Setenv("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory")
if got := issuer(); strings.Contains(got, "staging") {
t.Fatalf("an issuer was named explicitly and %q was used instead", got)
}
}
// A certificate is only ever asked for on a name the mesh routes here.
//
// **Without this, anything that can reach the port spends the quota.** A scan sending arbitrary
// names, or one misconfigured client, becomes a stream of failed orders against the account's
// 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"}))
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil {
t.Errorf("a name the mesh routes here was refused a certificate: %v", err)
}
for _, name := range []string{"unknown.example", "", "photos.example.evil"} {
if err := policy(context.Background(), name); err == nil {
t.Errorf("a certificate would be ordered for %q, which the mesh never mentioned", name)
}
}
}
// 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"}))
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil {
t.Fatal(err)
}
held.set(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")
}
}