Work breakdown 1.4. The mesh's own authority certifies internal names and always did; a name reachable from outside needs one the world already trusts, and there was no ACME anywhere in this repository. Uses acme/autocert from x/crypto, which was already a dependency — one indirect addition (x/net, for idna) and no new direct one. Three things worth more than the feature: **Staging is the default** (novox/hq 04-ISSUES/004). Production issuance is rate-limited per domain and per account and does not replenish quickly. Defaulting to production would leave the safe path depending on remembering to opt out, on exactly the work most likely to iterate. A staging certificate is trusted by no browser, so the mistake announces itself on the first request rather than a fortnight later. **A certificate is only asked for on a name the mesh routes here.** Without that policy, anything that can reach the port and send a name triggers an order for it — a scan becomes a stream of failed orders against the account's rate limit, and the proxy looks healthy throughout. What it may certify is what it was told to serve. **A private issuer is trusted by naming a file, never by skipping verification.** Skip would still apply on the day this points at a public issuer, and nothing would say so. TLS is opt-in: without TLS_LISTEN the proxy serves plain HTTP exactly as before, which is what an internal-only mesh wants. With it and no cache, it refuses rather than defaulting — every restart would otherwise order new certificates, silently, until the rate limit says it does not.
199 lines
7.3 KiB
Go
199 lines
7.3 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
|
|
}
|
|
|
|
// 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 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 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 || 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(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(map[string]string{
|
|
"going.example": "http://a.internal:80",
|
|
"staying.example": "http://b.internal:80",
|
|
})
|
|
held.set(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(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(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(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")
|
|
}
|
|
}
|