Proxy configuration beside insecure, not a fifth policy — ADR 0108 closed that set at four, and both of these tune how a request is carried rather than deciding what a name admits. A registry is the case that needs it: image layers arrive as single requests of gigabytes and a proxy's own default refuses them long before the workload is reached. Absent is no limit, which is what every route already got. A limit that is not a whole positive number of bytes takes the route with it, named in the log like a port that is not one — serving it without the limit would carry exactly what the module said not to carry. Enforced on the declared length where there is one, and while reading for a chunked body, which declares none: without the second, a limit is advice.
491 lines
18 KiB
Go
491 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
}
|
|
|
|
// 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 {
|
|
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 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":[
|
|
{"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)
|
|
}
|
|
}
|
|
|
|
// A route may name a target reached over https, for a backend that terminates its own TLS — the
|
|
// shape Mailu's webmail front needs, which this proxy reaches as a plain workload otherwise.
|
|
func TestARouteMayTargetHttps(t *testing.T) {
|
|
routes, _, err := routesFrom(write(t, `{"given":[
|
|
{"from":"mail","node":"anchor","at":"anchor.internal",
|
|
"values":{"name":"mail.example","port":7443,"scheme":"https","insecure":true}}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if targetOf(routes, "mail.example") != "https://anchor.internal:7443" {
|
|
t.Fatalf("an https target was not built as one: %v", routes)
|
|
}
|
|
if !routes["mail.example"][0].insecure {
|
|
t.Fatal("insecure was declared and not carried onto the rule")
|
|
}
|
|
}
|
|
|
|
// A scheme that is neither http nor https is refused rather than guessed at.
|
|
func TestARouteWithAnUnknownSchemeIsSkipped(t *testing.T) {
|
|
routes, _, err := routesFrom(write(t, `{"given":[
|
|
{"from":"a","node":"n","at":"n.internal","values":{"name":"bad.example","port":80,"scheme":"ftp"}}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(routes) != 0 {
|
|
t.Fatalf("a route with an unusable scheme was served: %v", routes)
|
|
}
|
|
}
|
|
|
|
// End to end: a backend terminating TLS with a certificate nothing would ordinarily trust is still
|
|
// reached when the route declared `insecure`, and the response comes back through unmodified.
|
|
func TestTheProxyReachesAnInsecureHttpsBackend(t *testing.T) {
|
|
workload := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("the workload, over its own TLS"))
|
|
}))
|
|
defer workload.Close()
|
|
target := strings.TrimPrefix(workload.URL, "https://")
|
|
|
|
held := newTable()
|
|
routes := map[string][]rule{"mail.example": {{target: "https://" + target, insecure: true}}}
|
|
held.set(routes, allPublic(routes))
|
|
|
|
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 = "mail.example"
|
|
answer, err := http.DefaultClient.Do(asked)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer answer.Body.Close()
|
|
if answer.StatusCode != http.StatusOK {
|
|
t.Fatalf("an insecure https backend was not reached: %d", answer.StatusCode)
|
|
}
|
|
}
|
|
|
|
// 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}), allPublic(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()
|
|
initial := plain(map[string]string{
|
|
"going.example": "http://a.internal:80",
|
|
"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")
|
|
}
|
|
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"}), 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")
|
|
}
|
|
}
|
|
|
|
// 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"}), 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.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 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 certificate is asked of the *internal* authority only for a name that is routed here and is
|
|
// not a route's own public name — the internal-network alias, never the route it accompanies.
|
|
func TestTheInternalAuthorityOnlyCertifiesInternalOnlyAliases(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 := onlyInternalNamesTheMeshSaid(held)
|
|
|
|
if err := policy(context.Background(), "app.anchor.internal"); err != nil {
|
|
t.Errorf("the internal alias was refused by its own authority: %v", err)
|
|
}
|
|
if err := policy(context.Background(), "app.example"); err == nil {
|
|
t.Error("the internal authority certified a route's public name, which the public authority already covers")
|
|
}
|
|
if err := policy(context.Background(), "unrouted.internal"); err == nil {
|
|
t.Error("the internal authority certified a name nobody routed here")
|
|
}
|
|
}
|
|
|
|
// 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"}), 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, 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")
|
|
}
|
|
}
|
|
|
|
// A route may say the largest body it carries, and the proxy holds requests to it.
|
|
//
|
|
// The registry is why: image layers arrive as single requests of gigabytes, and a proxy's own
|
|
// default refuses them long before the workload is reached. It is configuration beside `insecure`,
|
|
// not a fifth policy — novox/hq ADR 0108 closed that set at four.
|
|
func TestARouteMayLimitTheBodyItCarries(t *testing.T) {
|
|
routes, _, err := routesFrom(write(t, `{"given":[
|
|
{"from":"registry","node":"anchor","at":"anchor.internal",
|
|
"values":{"name":"images.example","port":5000,"max-request-body":21474836480}}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rules := routes["images.example"]
|
|
if len(rules) != 1 {
|
|
t.Fatalf("the route is not served once: %v", routes)
|
|
}
|
|
if rules[0].maxRequestBody != 21474836480 {
|
|
t.Fatalf("the limit did not survive the contribution: %d", rules[0].maxRequestBody)
|
|
}
|
|
}
|
|
|
|
// Saying nothing leaves the route unlimited, which is what every route already got.
|
|
func TestARouteThatSaysNothingCarriesAnySize(t *testing.T) {
|
|
routes, _, err := routesFrom(write(t, `{"given":[
|
|
{"from":"app","node":"anchor","at":"anchor.internal","values":{"name":"app.example","port":8080}}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := routes["app.example"][0].maxRequestBody; got != 0 {
|
|
t.Fatalf("a route that asked for no limit got one: %d", got)
|
|
}
|
|
}
|
|
|
|
// A limit that is not a whole positive number of bytes takes the route with it. Serving it without
|
|
// the limit would carry exactly what the module said not to carry, and report success doing it.
|
|
func TestARouteWithAnUnusableLimitIsSkipped(t *testing.T) {
|
|
for _, asked := range []string{`"lots"`, `-1`, `0`, `1.5`} {
|
|
routes, _, err := routesFrom(write(t, `{"given":[
|
|
{"from":"registry","node":"anchor","at":"anchor.internal",
|
|
"values":{"name":"images.example","port":5000,"max-request-body":`+asked+`}}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(routes["images.example"]) != 0 {
|
|
t.Errorf("a route asking for a max-request-body of %s was served anyway: %v", asked, routes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A request larger than the route carries is refused by the proxy, with the limit named, and the
|
|
// workload never sees it. A request within it is proxied normally.
|
|
func TestABodyOverTheLimitIsRefusedAndOneUnderItIsCarried(t *testing.T) {
|
|
var reached int
|
|
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
reached++
|
|
fmt.Fprintf(w, "carried %d bytes", len(body))
|
|
}))
|
|
defer workload.Close()
|
|
|
|
at := strings.TrimPrefix(workload.URL, "http://")
|
|
host, port, _ := strings.Cut(at, ":")
|
|
routes, _, err := routesFrom(write(t, `{"given":[
|
|
{"from":"registry","node":"anchor","at":"`+host+`",
|
|
"values":{"name":"images.example","port":`+port+`,"max-request-body":8}}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
held := newTable()
|
|
held.set(routes, map[string]bool{})
|
|
proxy := httptest.NewServer(handler(held))
|
|
defer proxy.Close()
|
|
|
|
over, err := post(proxy.URL, "images.example", "123456789")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer over.Body.Close()
|
|
if over.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("a body over the limit answered %d, not 413", over.StatusCode)
|
|
}
|
|
if reached != 0 {
|
|
t.Fatalf("the workload was reached by a request the route said it would not carry")
|
|
}
|
|
|
|
under, err := post(proxy.URL, "images.example", "1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer under.Body.Close()
|
|
if under.StatusCode != http.StatusOK {
|
|
t.Fatalf("a body within the limit answered %d, not 200", under.StatusCode)
|
|
}
|
|
if reached != 1 {
|
|
t.Fatalf("the workload was not reached by a request within the limit")
|
|
}
|
|
}
|
|
|
|
// post sends a body to the proxy as the named route, since a route is found by the Host header.
|
|
func post(url, host, body string) (*http.Response, error) {
|
|
asked, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
asked.Host = host
|
|
asked.Header.Set("Content-Type", "application/octet-stream")
|
|
return http.DefaultClient.Do(asked)
|
|
}
|