From 2db225980201305c029d6a06e396d947a498839c Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Sun, 1 Nov 2026 11:15:49 -0500 Subject: [PATCH] Serve HTTPS for hostnames more than one label under grov.site - Sign a leaf per routed hostname, so names like app.capella.grov.site validate - Give unrouted names the shared wildcard, so tailnet peers can't force a signature per invented name --- CLAUDE.md | 2 +- README.md | 2 +- docs/content/comparison.mdx | 2 +- docs/content/concepts/https-and-dns.mdx | 4 +- internal/ca/issuer.go | 79 ++++++++++ internal/ca/issuer_test.go | 201 ++++++++++++++++++++++++ internal/daemon/server.go | 15 +- internal/proxy/proxy.go | 13 +- internal/proxy/proxy_test.go | 3 +- 9 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 internal/ca/issuer.go create mode 100644 internal/ca/issuer_test.go diff --git a/CLAUDE.md b/CLAUDE.md index d68eed4..516109b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Releases are cut by pushing a `v*` tag. No version is written down in the repo: | `lease` | port allocation, and what a lease's lifetime is | | `daemon` | the control protocol, the server, and where state lives | | `proxy` | hostname to loopback port, with the 503 pages | -| `ca` / `trust` | the local root, the wildcard leaf, and the trust stores | +| `ca` / `trust` | the local root, a leaf per routed hostname, and the trust stores | | `platform` | the only place GOOS branches; keeps build tags out of the rest | | `redirect` | the macOS pf rules, as text, so Linux can test them | | `shell` | text a shell will run: quoting values, the argv steps a privileged plan is made of, and naming grove itself | diff --git a/README.md b/README.md index 48d5520..95c70bb 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Grove takes a different approach. **Your tooling stays on your host.** `pnpm dev` runs as you, with your node and your files. Hot reload watches the real filesystem, your debugger attaches, and the paths in your editor are the paths the process sees. No VM to feed, no bind mount to wait on. -**Real hostnames, real HTTPS.** A wildcard certificate from a root your machine trusts, and real DNS under `grov.site`. No hosts file, no port in the URL, and secure cookies and OAuth redirects behave like production. Two branches are two origins, so they never share a session. `localhost:3000` and `localhost:3001` do, because cookies don't care about ports. +**Real hostnames, real HTTPS.** Certificates from a root your machine trusts, and real DNS under `grov.site`. No hosts file, no port in the URL, and secure cookies and OAuth redirects behave like production. Two branches are two origins, so they never share a session. `localhost:3000` and `localhost:3001` do, because cookies don't care about ports. **Adopting it isn't a team decision.** Under CI grove steps aside and runs your command untouched, and `--if-available` asks for the same anywhere. A teammate who never ran `grove install` runs the same scripts. Your lando project keeps working too. diff --git a/docs/content/comparison.mdx b/docs/content/comparison.mdx index fc89eb9..4247cc5 100644 --- a/docs/content/comparison.mdx +++ b/docs/content/comparison.mdx @@ -20,7 +20,7 @@ Grove takes a different approach. **Your tooling stays on your host.** `pnpm dev` runs as you, with your node and your files. Hot reload watches the real filesystem, your debugger attaches, and the paths in your editor are the paths the process sees. No VM to feed, no bind mount to wait on. -**Real hostnames, real HTTPS.** A wildcard certificate from a root your machine trusts, and real DNS under `grov.site`. No hosts file, no port in the URL, and secure cookies and OAuth redirects behave like production. Two branches are two origins, so they never share a session. `localhost:3000` and `localhost:3001` do, because cookies don't care about ports. +**Real hostnames, real HTTPS.** Certificates from a root your machine trusts, and real DNS under `grov.site`. No hosts file, no port in the URL, and secure cookies and OAuth redirects behave like production. Two branches are two origins, so they never share a session. `localhost:3000` and `localhost:3001` do, because cookies don't care about ports. **Adopting it isn't a team decision.** Under CI grove steps aside and runs your command untouched, and `--if-available` asks for the same anywhere. A teammate who never ran `grove install` runs the same scripts. Your lando project keeps working too. diff --git a/docs/content/concepts/https-and-dns.mdx b/docs/content/concepts/https-and-dns.mdx index 6e38bee..758ad38 100644 --- a/docs/content/concepts/https-and-dns.mdx +++ b/docs/content/concepts/https-and-dns.mdx @@ -1,11 +1,11 @@ --- title: HTTPS and DNS -description: A local root, a wildcard leaf, real DNS pointing at loopback, and why grove serves 127.0.0.4 rather than 127.0.0.1. +description: A local root, leaves issued per hostname, real DNS pointing at loopback, and why grove serves 127.0.0.4 rather than 127.0.0.1. --- ## The certificate authority -`grove install` generates a root in your state directory and installs it into the system trust stores, which is the step that may ask for your password. The daemon issues a wildcard leaf for `*.grov.site` from that root, so every context's hostname is covered by one certificate and a new worktree needs nothing issued. +`grove install` generates a root in your state directory and installs it into the system trust stores, which is the step that may ask for your password. The daemon signs leaves from that root as they're asked for, so a new worktree needs nothing issued ahead of time. A hostname grove routes gets a leaf of its own, which is what lets it sit more than one label under `grov.site`, since a wildcard covers exactly one. Any other name gets a shared `*.grov.site` leaf, so a context that isn't running still reaches the page saying what to run, and a client offering made-up names can't make the daemon sign one leaf apiece. Runtimes that ignore the OS store get told separately. `grove exec` sets `NODE_EXTRA_CA_CERTS` to the root, and `REQUESTS_CA_BUNDLE` and `DENO_CERT` to a bundle carrying it, since those two replace a trust store rather than adding to it and so need the whole thing. Where the system's own bundle was updated by the install, that's the one used; otherwise grove's. `grove doctor` reports which, and `GROVE_SYSTEM_BUNDLE` names one by hand. diff --git a/internal/ca/issuer.go b/internal/ca/issuer.go new file mode 100644 index 0000000..3366646 --- /dev/null +++ b/internal/ca/issuer.go @@ -0,0 +1,79 @@ +package ca + +import ( + "crypto/tls" + "fmt" + "strings" + "sync" +) + +// Issuer hands out a leaf per hostname instead of one wildcard, so a context can +// sit at any depth under the domain. A wildcard certificate matches exactly one +// label (RFC 6125), which is what confined grove to .; DNS has +// no such limit and already resolves deeper names. +type Issuer struct { + root *CA + domain string + routed func(name string) bool + + mu sync.Mutex + leaves map[string]*tls.Certificate + minted int +} + +// Only names routed asks about get a leaf of their own. Once the daemon answers +// on the tailnet, minting for any name a client offers would let a peer fill +// memory with one signed leaf per invented hostname. +func NewIssuer(root *CA, domain string, routed func(name string) bool) *Issuer { + return &Issuer{ + root: root, + domain: strings.ToLower(domain), + routed: routed, + leaves: map[string]*tls.Certificate{}, + } +} + +// GetCertificate satisfies tls.Config. An empty ServerName means a client that +// dialled the address rather than a name, which still gets the wildcard. +func (i *Issuer) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + name := strings.ToLower(strings.TrimSuffix(hello.ServerName, ".")) + wildcard := "*." + i.domain + if name == "" { + name = wildcard + } + // Minting for whatever is asked would make a root the machine trusts into a + // signing oracle for names grove has no claim to. + if name != i.domain && !strings.HasSuffix(name, "."+i.domain) && name != wildcard { + return nil, fmt.Errorf("ca: %q is not under %s", name, i.domain) + } + // An unrouted name still gets the wildcard, so one label deep it reaches the + // proxy's 503 page, which says what to run, rather than a handshake failure. + if name != wildcard && !i.routed(name) { + name = wildcard + } + + i.mu.Lock() + defer i.mu.Unlock() + if leaf, ok := i.leaves[name]; ok { + return leaf, nil + } + names := []string{name} + if name == wildcard { + names = append(names, i.domain) + } + leaf, err := i.root.Leaf(names...) + if err != nil { + return nil, err + } + i.leaves[name] = leaf + i.minted++ + return leaf, nil +} + +// Minted reports how many leaves have been signed, so tests can see the cache +// and the routed check at work. +func (i *Issuer) Minted() int { + i.mu.Lock() + defer i.mu.Unlock() + return i.minted +} diff --git a/internal/ca/issuer_test.go b/internal/ca/issuer_test.go new file mode 100644 index 0000000..d3b1252 --- /dev/null +++ b/internal/ca/issuer_test.go @@ -0,0 +1,201 @@ +package ca_test + +import ( + "bufio" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/grove-sh/cli/internal/ca" + "github.com/grove-sh/cli/internal/proxy" +) + +const domain = "grov.site" + +func everyName(string) bool { return true } + +func issuer(t *testing.T) (*ca.Issuer, *x509.CertPool) { + t.Helper() + root, err := ca.OpenOrCreate(t.TempDir()) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(root.RootPEM()) { + t.Fatal("root not added to pool") + } + return ca.NewIssuer(root, domain, everyName), pool +} + +func TestIssuerServesAnyDepthUnderTheDomain(t *testing.T) { + iss, pool := issuer(t) + + for _, name := range []string{ + "app." + domain, + "my-app.org." + domain, + "web.my-app.org." + domain, + domain, + } { + leaf, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: name}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := leaf.Leaf.VerifyHostname(name); err != nil { + t.Errorf("%s: %v", name, err) + } + if _, err := leaf.Leaf.Verify(x509.VerifyOptions{ + DNSName: name, Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }); err != nil { + t.Errorf("%s does not chain to the root: %v", name, err) + } + } +} + +func TestIssuerRefusesNamesItHasNoClaimTo(t *testing.T) { + iss, _ := issuer(t) + + for _, name := range []string{"example.com", "grov.site.evil.com", "notgrov.site"} { + if _, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: name}); err == nil { + t.Errorf("%s: minted a leaf for a name outside the domain", name) + } + } +} + +func TestIssuerCachesPerName(t *testing.T) { + iss, _ := issuer(t) + + for i := 0; i < 5; i++ { + if _, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: "app." + domain}); err != nil { + t.Fatal(err) + } + } + if got := iss.Minted(); got != 1 { + t.Errorf("minted %d leaves for one name, want 1", got) + } +} + +// Over the tailnet any peer can offer any hostname, so only routed names may +// cost a signature; the rest share the wildcard. +func TestIssuerMintsOnlyForRoutedNames(t *testing.T) { + root, err := ca.OpenOrCreate(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const routed = "app.capella." + domain + iss := ca.NewIssuer(root, domain, func(name string) bool { return name == routed }) + + leaf, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: routed}) + if err != nil { + t.Fatal(err) + } + if err := leaf.Leaf.VerifyHostname(routed); err != nil { + t.Errorf("routed name got a leaf that does not cover it: %v", err) + } + + for i := 0; i < 50; i++ { + name := fmt.Sprintf("x%d.capella.%s", i, domain) + leaf, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: name}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := leaf.Leaf.VerifyHostname("anything." + domain); err != nil { + t.Errorf("%s: unrouted name did not get the wildcard: %v", name, err) + } + } + leaf, err = iss.GetCertificate(&tls.ClientHelloInfo{ServerName: domain}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{domain, "anything." + domain} { + if err := leaf.Leaf.VerifyHostname(name); err != nil { + t.Errorf("unrouted apex did not get the shared wildcard covering %s: %v", name, err) + } + } + if got := iss.Minted(); got != 2 { + t.Errorf("minted %d leaves, want 2: one routed name and the shared wildcard", got) + } +} + +// The point of the spike: a real handshake and a routed response on a hostname +// two labels deep, which a wildcard leaf cannot cover. +func TestMultiLevelHostnameHandshakesAndRoutes(t *testing.T) { + root, err := ca.OpenOrCreate(t.TempDir()) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(root.RootPEM()) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "served %s", r.Host) + })) + t.Cleanup(upstream.Close) + + const host = "my-app.org." + domain + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := proxy.New() + p.SetRoutes([]proxy.Route{{Host: host, Upstream: strings.TrimPrefix(upstream.URL, "http://")}}) + iss := ca.NewIssuer(root, domain, p.Routes) + go p.Serve(ln, iss.GetCertificate) + t.Cleanup(func() { ln.Close() }) + + conn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ServerName: host, RootCAs: pool}) + if err != nil { + t.Fatalf("handshake for %s: %v", host, err) + } + defer conn.Close() + + req, _ := http.NewRequest("GET", "https://"+host+"/", nil) + if err := req.Write(conn); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, want 200", resp.StatusCode) + } +} + +func BenchmarkIssueColdLeaf(b *testing.B) { + root, err := ca.OpenOrCreate(b.TempDir()) + if err != nil { + b.Fatal(err) + } + iss := ca.NewIssuer(root, domain, everyName) + b.ResetTimer() + for i := 0; i < b.N; i++ { + name := fmt.Sprintf("c%d.app.%s", i, domain) + if _, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: name}); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkIssueCachedLeaf(b *testing.B) { + root, err := ca.OpenOrCreate(b.TempDir()) + if err != nil { + b.Fatal(err) + } + iss := ca.NewIssuer(root, domain, everyName) + hello := &tls.ClientHelloInfo{ServerName: "app." + domain} + if _, err := iss.GetCertificate(hello); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := iss.GetCertificate(hello); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 0462714..edee017 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -2,7 +2,6 @@ package daemon import ( "context" - "crypto/tls" "encoding/json" "errors" "fmt" @@ -41,7 +40,7 @@ type Server struct { // no reason to report on it or prune it. memory recordFile proxy *proxy.Server - cert *tls.Certificate + issuer *ca.Issuer root []byte version string @@ -72,10 +71,8 @@ func New(cfg Config) (*Server, error) { } return nil, err } - cert, err := root.Leaf("*."+cfg.Domain, cfg.Domain) - if err != nil { - return nil, err - } + prx := proxy.New() + issuer := ca.NewIssuer(root, cfg.Domain, prx.Routes) // Beside the authority, so a daemon pointed elsewhere keeps its allocations // there too. memory, err := lease.OpenMemory(filepath.Join(cfg.CADir, "ports.json")) @@ -91,8 +88,8 @@ func New(cfg Config) (*Server, error) { version: cfg.Version, registry: registry, memory: memory, - proxy: proxy.New(), - cert: cert, + proxy: prx, + issuer: issuer, root: root.RootPEM(), conns: make(map[net.Conn]struct{}), routed: make(map[routeKey][]string), @@ -149,7 +146,7 @@ func (s *Server) Serve(control, https, http net.Listener) error { failed := make(chan error, 3) go func() { failed <- s.serveControl(control) }() - go func() { failed <- s.proxy.Serve(https, s.cert) }() + go func() { failed <- s.proxy.Serve(https, s.issuer.GetCertificate) }() if redirect != nil { go func() { failed <- redirect.Serve(http) }() } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 952d328..a510351 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -44,12 +44,15 @@ func (s *Server) SetRoutes(routes []Route) { s.routes.Store(&table) } -func (s *Server) Serve(ln net.Listener, cert *tls.Certificate) error { +func (s *Server) Routes(host string) bool { + _, ok := (*s.routes.Load())[strings.ToLower(host)] + return ok +} + +func (s *Server) Serve(ln net.Listener, certFor func(*tls.ClientHelloInfo) (*tls.Certificate, error)) error { s.http.TLSConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - return cert, nil - }, + MinVersion: tls.VersionTLS12, + GetCertificate: certFor, } return s.http.ServeTLS(ln, "", "") } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index b52463a..347d228 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -48,7 +48,8 @@ func newHarness(t *testing.T, routes []proxy.Route) *harness { s := proxy.New() s.SetRoutes(routes) go func() { - if err := s.Serve(ln, cert); err != nil && err != http.ErrServerClosed { + certFor := func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return cert, nil } + if err := s.Serve(ln, certFor); err != nil && err != http.ErrServerClosed { t.Log(err) } }()