diff --git a/examples/route-proxy/main.go b/examples/route-proxy/main.go index f50d322..6ae5440 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -20,6 +20,9 @@ package main import ( + "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "log" @@ -32,8 +35,48 @@ import ( "strings" "sync" "time" + + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" ) +// Where public certificates come from when nothing says otherwise. +// +// **Staging, deliberately** (novox/hq 04-ISSUES/004). Production issuance is rate-limited per +// domain and per account, the quota does not replenish quickly, and exhausting it removes the +// ability to issue a certificate somebody actually needs. Defaulting to production would leave +// the safe path depending on remembering to opt out of it, on exactly the work most likely to +// iterate — standing up a node, changing how names resolve. +// +// A staging certificate is trusted by no browser, so the mistake announces itself on the first +// request rather than a fortnight later at the rate limit. +const stagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory" + +// issuer is the ACME directory to ask. The lab points this at its own issuer; a node serving real +// traffic points it at production, and says so explicitly. +func issuer() string { + if named := strings.TrimSpace(os.Getenv("ACME_DIRECTORY")); named != "" { + return named + } + return stagingDirectory +} + +// onlyWhatTheMeshSaid refuses to obtain a certificate for a name this proxy was not given. +// +// **The policy that stops a quota from being spent by accident.** Without it, anything that can +// reach port 443 and send a name triggers an issuance attempt for it — so a scan, or one +// misconfigured client, becomes a stream of failed orders against the account's rate limit. What +// this proxy may certify is exactly what the mesh told it to route, which is already the answer +// to what it may serve. +func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { + return func(_ context.Context, host string) error { + if _, known := held.find(host); known { + return nil + } + return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host) + } +} + // what the mesh writes: the contributions file, one entry per consumer. type given struct { Given []contribution `json:"given"` @@ -134,7 +177,64 @@ func run() error { } }() - return http.ListenAndServe(listen, handler(held)) + // TLS is opt-in. A proxy with no `TLS_LISTEN` serves plain HTTP exactly as before — which is + // what an internal-only mesh wants, and what the mesh's own certificate authority already + // covers for names inside it (novox/hq 08-connectivity). This is for names reachable from + // outside, where the authority has to be one the world already trusts. + secure := strings.TrimSpace(os.Getenv("TLS_LISTEN")) + if secure == "" { + return http.ListenAndServe(listen, handler(held)) + } + + cache := strings.TrimSpace(os.Getenv("ACME_CACHE")) + if cache == "" { + // Refused rather than defaulted. Without somewhere durable to keep them, every restart + // orders new certificates — which works, silently, until the rate limit says it does not. + return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " + + "to persist, or every restart orders them again") + } + client := &acme.Client{DirectoryURL: issuer()} + // An issuer that is not one of the public ones serves its own API over TLS with a certificate + // nothing trusts yet — the lab's, or an internal step-ca. Trusting it is a deliberate act and + // names a file, rather than the client being told to skip verification: *skip* would also + // apply on the day this points at a public issuer, and nothing would say so. + if bundle := strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")); bundle != "" { + pem, err := os.ReadFile(bundle) + if err != nil { + return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return fmt.Errorf("%s holds no certificate this can trust", bundle) + } + client.HTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, + } + } + manager := &autocert.Manager{ + Cache: autocert.DirCache(cache), + Prompt: autocert.AcceptTOS, + HostPolicy: onlyWhatTheMeshSaid(held), + Client: client, + } + log.Printf("issuing from %s, for whatever the mesh routes here", issuer()) + + // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge + // must be answered *at the name being certified*, which is why issuance happens on the node + // that is publicly reachable rather than wherever the workload runs. + go func() { + if err := http.ListenAndServe(listen, manager.HTTPHandler(handler(held))); err != nil { + log.Printf("plain HTTP stopped: %v", err) + } + }() + + server := &http.Server{ + Addr: secure, + Handler: handler(held), + TLSConfig: manager.TLSConfig(), + } + return server.ListenAndServeTLS("", "") } // newTable is an empty routing table. diff --git a/examples/route-proxy/routes_test.go b/examples/route-proxy/routes_test.go index 628ed7b..6740569 100644 --- a/examples/route-proxy/routes_test.go +++ b/examples/route-proxy/routes_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "net/http" "net/http/httptest" "os" @@ -141,3 +142,57 @@ func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { 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") + } +} diff --git a/go.mod b/go.mod index 5cff5ce..2cee974 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/go.sum b/go.sum index 478b2c7..f1c77ed 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=