Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/comparison.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/content/concepts/https-and-dns.mdx
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
79 changes: 79 additions & 0 deletions internal/ca/issuer.go
Original file line number Diff line number Diff line change
@@ -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 <context>.<domain>; 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
}
201 changes: 201 additions & 0 deletions internal/ca/issuer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
15 changes: 6 additions & 9 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package daemon

import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand All @@ -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),
Expand Down Expand Up @@ -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) }()
}
Expand Down
13 changes: 8 additions & 5 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "", "")
}
Expand Down
Loading
Loading