sdk1: route-proxy moves here; its composition proofs move to the node-engine #2
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user