The proxy can obtain a public certificate, and asks staging by default
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.
This commit is contained in:
@@ -20,6 +20,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -32,8 +35,48 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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.
|
// what the mesh writes: the contributions file, one entry per consumer.
|
||||||
type given struct {
|
type given struct {
|
||||||
Given []contribution `json:"given"`
|
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.
|
// newTable is an empty routing table.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
|||||||
@@ -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=
|
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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
|||||||
Reference in New Issue
Block a user