From 2a1572f090570fefcb66bbd79477a3da09f808c3 Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Sun, 1 Nov 2026 11:37:40 -0500 Subject: [PATCH 1/3] Reach a machine's contexts from other devices on Tailscale - grove share gives every hostname the machine's name and serves them on the tailnet, standing down on any other - Never takes tailnet port 443 from another Serve handler such as t3code - doctor names whichever part of sharing is missing --- CLAUDE.md | 5 +- cmd/grove/context.go | 4 +- cmd/grove/daemon.go | 16 +- cmd/grove/doctor.go | 5 +- cmd/grove/doctor_share.go | 136 ++++++++++++ cmd/grove/doctor_share_test.go | 232 ++++++++++++++++++++ cmd/grove/exec.go | 2 +- cmd/grove/main.go | 1 + cmd/grove/share.go | 237 ++++++++++++++++++++ docs/content/concepts/https-and-dns.mdx | 4 + docs/content/guides/meta.ts | 1 + docs/content/guides/tailnet.mdx | 56 +++++ docs/content/guides/troubleshooting.mdx | 1 + docs/content/reference/cli/index.mdx | 5 +- docs/content/reference/cli/meta.ts | 1 + docs/content/reference/cli/share.mdx | 40 ++++ internal/daemon/server.go | 53 ++++- internal/daemon/sharing.go | 169 +++++++++++++++ internal/daemon/sharing_test.go | 222 +++++++++++++++++++ internal/platform/platform_darwin.go | 4 + internal/platform/platform_linux.go | 58 +++-- internal/platform/platform_linux_test.go | 24 ++- internal/platform/platform_other.go | 4 + internal/tailnet/dns.go | 119 ++++++++++ internal/tailnet/dns_test.go | 95 ++++++++ internal/tailnet/share.go | 65 ++++++ internal/tailnet/tailnet.go | 255 ++++++++++++++++++++++ internal/tailnet/tailnet_test.go | 262 +++++++++++++++++++++++ 28 files changed, 2048 insertions(+), 28 deletions(-) create mode 100644 cmd/grove/doctor_share.go create mode 100644 cmd/grove/doctor_share_test.go create mode 100644 cmd/grove/share.go create mode 100644 docs/content/guides/tailnet.mdx create mode 100644 docs/content/reference/cli/share.mdx create mode 100644 internal/daemon/sharing.go create mode 100644 internal/daemon/sharing_test.go create mode 100644 internal/tailnet/dns.go create mode 100644 internal/tailnet/dns_test.go create mode 100644 internal/tailnet/share.go create mode 100644 internal/tailnet/tailnet.go create mode 100644 internal/tailnet/tailnet_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 516109b..8e96dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Releases are cut by pushing a `v*` tag. No version is written down in the repo: ## Layout -`cmd/grove` is the cobra CLI, one file per command. Beyond `exec`, `ls`, `hold`, `release`, `forget`, and the `start`/`stop`/`restart` trio, that is `install`/`uninstall` (the privileged and trust-store path), `doctor` (a finding per check, with a remedy where there is one to name, and usually where a platform change needs its matching edit), `init`, `env`, and `context`. `internal/*` holds everything with logic in it: +`cmd/grove` is the cobra CLI, one file per command. Beyond `exec`, `ls`, `hold`, `release`, `forget`, and the `start`/`stop`/`restart` trio, that is `install`/`uninstall` (the privileged and trust-store path), `share` (reaching contexts from other devices on a tailnet), `doctor` (a finding per check, with a remedy where there is one to name, and usually where a platform change needs its matching edit), `init`, `env`, and `context`. `internal/*` holds everything with logic in it: | | | |-|-| @@ -32,6 +32,7 @@ Releases are cut by pushing a `v*` tag. No version is written down in the repo: | `proxy` | hostname to loopback port, with the 503 pages | | `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 | +| `tailnet` | tailscaled's local API, the share record and zone, the Serve mapping for 443, and the split DNS resolver | | `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 | @@ -82,6 +83,8 @@ The four places still naming `127.0.0.1` are the other side of the proxy, where **What install prints is tested by pasting it.** The `macos-443` job greps `^[[:space:]]+sudo ` out of `grove install`'s output, runs those lines, and asserts `pf sends 443 to`. So the two-space indent in `platform_*.go` and that phrasing in `internal/redirect` are load-bearing with no Go test covering either: a job reconstructing the instructions could pass while the real ones were wrong. It points `GROVE_STATE_DIR` at a path containing a space on purpose, because splitting those paths into the wrong number of arguments once passed CI without one. The `linux-443` job and the tail of `macos-443` take the other path, `uninstall --yes` then `install --yes`, so grove running the steps itself is covered too. +**Sharing moves hostnames one label down.** `grove share` writes `share.json` beside the CA, naming a tailnet and the machine's MagicDNS label, and from then on `tailnet.Zone` makes every hostname `..grov.site`, in the CLI (`zone()` in `cmd/grove/share.go`) and the daemon (`Config.Zone`) alike. The record is held rather than re-read from Tailscale on purpose: hostnames must not move when tailscaled stops or switches tailnets. The daemon's `sharing` loop asks tailscaled's local API every ten seconds, and only on the recorded tailnet does it bind the split DNS resolver on the tailnet address and claim tailnet 443 through Serve as a raw TCP forward to its own listener. It never takes 443 from another Serve handler (t3code's is the usual one), and it releases its own mapping on any other tailnet. It also re-reads `share.json` on every check and stands down when the record is gone or changed, since otherwise `share --off` would be undone within ten seconds by a daemon still holding the share it started with. Linux only: `platform.TailscaleSocket` is empty elsewhere. + `grov.site` is real wildcard DNS pointing at `platform.Address`, so there are no hosts-file entries to manage. `*.v4.grov.site` also points there and is kept for the 0.3.0 betas, which shipped while `*.grov.site` still answered `127.0.0.1`: deleting it would break a published version. The domain is not a config key yet: `defaultDomain` in `cmd/grove/main.go` is a constant, and nothing in `internal/config` reads a domain. ## Conventions in this codebase diff --git a/cmd/grove/context.go b/cmd/grove/context.go index 2993d1d..db0f323 100644 --- a/cmd/grove/context.go +++ b/cmd/grove/context.go @@ -70,13 +70,13 @@ func writeJSON(cmd *cobra.Command, ctx identity.Context) error { return enc.Encode(struct { identity.Context Host string `json:"host"` - }{ctx, ctx.Host(defaultDomain)}) + }{ctx, ctx.Host(zone())}) } func writeTable(cmd *cobra.Command, ctx identity.Context) error { w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) rows := [][2]string{ - {"host", ctx.Host(defaultDomain)}, + {"host", ctx.Host(zone())}, {"project", ctx.Project}, {"variant", ctx.Variant}, {"slug", ctx.Slug}, diff --git a/cmd/grove/daemon.go b/cmd/grove/daemon.go index 91a3fc7..02e73c7 100644 --- a/cmd/grove/daemon.go +++ b/cmd/grove/daemon.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "log" "net" "os" "os/signal" @@ -16,6 +17,7 @@ import ( "github.com/grove-sh/cli/internal/ca" "github.com/grove-sh/cli/internal/daemon" "github.com/grove-sh/cli/internal/platform" + "github.com/grove-sh/cli/internal/tailnet" ) type daemonOptions struct { @@ -138,7 +140,19 @@ for it, so stopping the daemon drops all of them at once.`, Hidden: true, Args: usageArgs(cobra.NoArgs), RunE: func(cmd *cobra.Command, _ []string) error { - server, err := daemon.New(daemon.Config{Domain: opts.domain, CADir: opts.caDir, Version: resolveVersion()}) + cfg := daemon.Config{Domain: opts.domain, CADir: opts.caDir, Version: resolveVersion()} + record, shared, err := tailnet.LoadShare(opts.caDir) + if err != nil { + return fmt.Errorf("%w\nrun %s share to write it again, or %s share --off", err, invocation(), invocation()) + } + if shared { + cfg.Zone = tailnet.Zone(opts.domain, record, true) + cfg.Share = &record + cfg.Tailscale = tailnet.New(platform.TailscaleSocket()) + cfg.Target = opts.listen + cfg.Logf = log.New(os.Stderr, "", log.LstdFlags).Printf + } + server, err := daemon.New(cfg) if err != nil { return err } diff --git a/cmd/grove/doctor.go b/cmd/grove/doctor.go index 6261161..3211eec 100644 --- a/cmd/grove/doctor.go +++ b/cmd/grove/doctor.go @@ -55,7 +55,7 @@ func newDoctorCommand() *cobra.Command { cmd := &cobra.Command{ Use: "doctor", - Short: "Check DNS, trust, grove itself, and port 443", + Short: "Check DNS, trust, grove itself, port 443, and sharing", Long: `Check the things that have to be true before grove can serve a hostname. Each check reports on its own. A failure exits non-zero so this can gate a @@ -86,6 +86,9 @@ script; a warning does not.`, findings = append(findings, f) } } + if f, worth := checkSharing(stateDir, defaultDaemonOptions().listen); worth { + findings = append(findings, f) + } // Reported even when fine, unlike those: which worktree is the // project answers why a hostname is what it is. if f, worth := checkMainWorktree(dir); worth { diff --git a/cmd/grove/doctor_share.go b/cmd/grove/doctor_share.go new file mode 100644 index 0000000..582d7bc --- /dev/null +++ b/cmd/grove/doctor_share.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "slices" + "time" + + "github.com/grove-sh/cli/internal/platform" + "github.com/grove-sh/cli/internal/tailnet" +) + +type shareProbe struct { + record tailnet.Share + shared bool + recordErr error + + status tailnet.Status + statusErr error + + holder tailnet.Holder + holderErr error + // Queried directly to distinguish a missing resolver from missing split DNS. + resolver []netip.Addr + resolverErr error + system []netip.Addr +} + +func checkSharing(stateDir, listen string) (finding, bool) { + p := shareProbe{} + p.record, p.shared, p.recordErr = tailnet.LoadShare(stateDir) + if !p.shared && p.recordErr == nil { + return finding{}, false + } + if p.recordErr == nil { + gatherShare(&p, listen) + } + return judgeSharing(p), true +} + +func gatherShare(p *shareProbe, listen string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := tailnet.New(platform.TailscaleSocket()) + p.status, p.statusErr = client.Status(ctx) + if p.statusErr != nil || !p.status.Running || p.status.Tailnet != p.record.Tailnet || !p.status.IPv4.IsValid() { + return + } + if cfg, err := client.ServeConfig(ctx); err != nil { + p.holderErr = err + } else { + p.holder, p.holderErr = cfg.Port443(listen) + } + + name := "grove-doctor." + tailnet.Zone(defaultDomain, p.record, true) + direct := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp", net.JoinHostPort(p.status.IPv4.String(), "53")) + }} + p.resolver, p.resolverErr = direct.LookupNetIP(ctx, "ip4", name) + p.system, _ = net.DefaultResolver.LookupNetIP(ctx, "ip4", name) +} + +func judgeSharing(p shareProbe) finding { + f := finding{name: "Sharing"} + if p.recordErr != nil { + f.state = bad + f.detail = "the share record cannot be read" + f.advice = p.recordErr.Error() + ". Run " + invocation() + " share to write it again, or " + invocation() + " share --off." + return f + } + zone := tailnet.Zone(defaultDomain, p.record, true) + switch { + case errors.Is(p.statusErr, tailnet.ErrUnavailable): + f.state = warn + f.detail = "paused, since tailscaled is not answering" + f.advice = "Hostnames stay *." + zone + " and resolve to this machine only. Check tailscale status." + return f + case p.statusErr != nil: + f.state = warn + f.detail = "paused: " + p.statusErr.Error() + return f + case !p.status.Running: + f.state = warn + f.detail = "paused, since tailscale is not connected" + f.advice = "Run tailscale up and grove picks sharing back up on its own." + return f + case p.status.Tailnet != p.record.Tailnet: + f.state = warn + f.detail = fmt.Sprintf("paused on %s; this machine shares on %s", p.status.Tailnet, p.record.Tailnet) + f.advice = "Nothing is offered on this tailnet. To share here instead, run " + invocation() + " share." + return f + case !p.status.IPv4.IsValid(): + f.state = warn + f.detail = "paused, since tailscale reports no IPv4 address" + return f + } + + ip := p.status.IPv4 + if p.resolverErr != nil || !slices.Contains(p.resolver, ip) { + f.state = bad + f.detail = fmt.Sprintf("nothing answers DNS for *.%s on %s:53", zone, ip) + f.advice = "The daemon answers there while it shares. If it is running an older build, run " + invocation() + " restart. " + + "If port 53 is refused, run " + invocation() + " share again, which offers to lower the port floor. Its log, daemon.log in the state directory, says which." + return f + } + if !slices.Contains(p.system, ip) { + f.state = warn + f.detail = fmt.Sprintf("*.%s does not resolve to %s through this machine's DNS", zone, ip) + f.advice = "Add the split DNS entry in " + adminDNS + ": a custom nameserver " + ip.String() + ", restricted to " + zone + ". Each device also needs Use Tailscale DNS turned on." + return f + } + switch { + case p.holderErr != nil: + f.state = warn + f.detail = "cannot tell who holds tailnet port 443: " + p.holderErr.Error() + return f + case p.holder == tailnet.Other: + f.state = warn + f.detail = "tailnet port 443 is served by something else" + f.advice = "Other devices resolve these hostnames and then reach that instead. Move it to another port (t3code: Settings, Tailscale Serve port) and grove takes 443 within ten seconds." + return f + case p.holder == tailnet.Free: + f.state = warn + f.detail = "tailnet port 443 is not forwarded to grove yet" + f.advice = "The daemon claims it while it shares. If it is running an older build, run " + invocation() + " restart. " + + "If this user may not change Tailscale Serve, run " + tailnet.OperatorFix + "; daemon.log in the state directory says which." + return f + } + f.state = ok + f.detail = fmt.Sprintf("*.%s on %s, at %s", zone, p.record.Tailnet, ip) + return f +} diff --git a/cmd/grove/doctor_share_test.go b/cmd/grove/doctor_share_test.go new file mode 100644 index 0000000..6f5e3d1 --- /dev/null +++ b/cmd/grove/doctor_share_test.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/grove-sh/cli/internal/tailnet" +) + +var tailnetIP = netip.MustParseAddr("100.76.35.70") + +func healthyShare() shareProbe { + return shareProbe{ + record: tailnet.Share{Tailnet: "dreamops.dev", Host: "capella"}, + shared: true, + status: tailnet.Status{Running: true, Tailnet: "dreamops.dev", Host: "capella", IPv4: tailnetIP}, + holder: tailnet.Ours, + resolver: []netip.Addr{tailnetIP}, + system: []netip.Addr{tailnetIP}, + } +} + +func TestSharingFindings(t *testing.T) { + cases := []struct { + name string + change func(*shareProbe) + state string + detail string + advice string + }{ + {"healthy", func(*shareProbe) {}, ok, "*.capella.grov.site on dreamops.dev", ""}, + {"unreadable record", func(p *shareProbe) { p.recordErr = errors.New("not a share record") }, bad, "cannot be read", "share --off"}, + {"tailscaled down", func(p *shareProbe) { p.statusErr = tailnet.ErrUnavailable }, warn, "not answering", "tailscale status"}, + {"logged out", func(p *shareProbe) { p.status.Running = false }, warn, "not connected", "tailscale up"}, + {"another tailnet", func(p *shareProbe) { p.status.Tailnet = "client.example" }, warn, "paused on client.example", "share"}, + {"resolver silent", func(p *shareProbe) { p.resolver, p.resolverErr = nil, errors.New("refused") }, bad, "nothing answers DNS", "port floor"}, + // The public wildcard answering means the split DNS entry is missing. + {"no split DNS", func(p *shareProbe) { p.system = []netip.Addr{netip.MustParseAddr("127.0.0.4")} }, warn, "does not resolve to 100.76.35.70", "restricted to capella.grov.site"}, + {"443 held", func(p *shareProbe) { p.holder = tailnet.Other }, warn, "served by something else", "t3code"}, + {"443 free", func(p *shareProbe) { p.holder = tailnet.Free }, warn, "not forwarded", "restart"}, + {"443 free, maybe denied", func(p *shareProbe) { p.holder = tailnet.Free }, warn, "not forwarded", tailnet.OperatorFix}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := healthyShare() + c.change(&p) + f := judgeSharing(p) + if f.state != c.state || !strings.Contains(f.detail, c.detail) || !strings.Contains(f.advice, c.advice) { + t.Errorf("got state=%s detail=%q advice=%q", f.state, f.detail, f.advice) + } + }) + } +} + +func TestSharingSilentWhenNotShared(t *testing.T) { + if _, worth := checkSharing(t.TempDir(), "127.0.0.4:443"); worth { + t.Error("reported on a machine that never shared") + } +} + +type fakeServe struct { + serve string + denied bool + unreadable bool +} + +func (f *fakeServe) Status(context.Context) (tailnet.Status, error) { + return tailnet.Status{Running: true, Tailnet: "dreamops.dev"}, nil +} + +func (f *fakeServe) ServeConfig(context.Context) (*tailnet.ServeConfig, error) { + if f.unreadable { + return nil, tailnet.ErrUnavailable + } + return tailnet.ParseServeConfig([]byte(f.serve), "") +} + +func (f *fakeServe) SetServeConfig(_ context.Context, cfg *tailnet.ServeConfig) error { + if f.denied { + return tailnet.ErrDenied + } + raw, err := cfg.MarshalJSON() + f.serve = string(raw) + return err +} + +func TestReleaseSaysWhatBecameOfTheMapping(t *testing.T) { + ours := `{"TCP":{"443":{"TCPForward":"` + defaultDaemonOptions().listen + `"}}}` + cases := []struct { + name string + f *fakeServe + tone string + said string + serves bool + }{ + {"released", &fakeServe{serve: ours}, ok, "released", false}, + {"someone else's", &fakeServe{serve: `{"TCP":{"443":{"HTTPS":true}}}`}, ok, "was not grove's", true}, + {"denied", &fakeServe{serve: ours, denied: true}, bad, tailnet.OperatorFix, true}, + {"unreadable", &fakeServe{serve: ours, unreadable: true}, bad, "cannot tell", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + said, tone, _ := releaseNow(context.Background(), c.f) + if tone != c.tone || !strings.Contains(said, c.said) { + t.Errorf("got tone=%s said=%q", tone, said) + } + if got := strings.Contains(c.f.serve, `"443"`); got != c.serves { + t.Errorf("443 still mapped = %v, want %v: %s", got, c.serves, c.f.serve) + } + }) + } +} + +func TestUnshareRetryAfterDenialReleases(t *testing.T) { + dir := t.TempDir() + if err := tailnet.SaveShare(dir, tailnet.Share{Tailnet: "dreamops.dev", Host: "capella"}); err != nil { + t.Fatal(err) + } + f := &fakeServe{serve: `{"TCP":{"443":{"TCPForward":"` + defaultDaemonOptions().listen + `"}}}`, denied: true} + + var out bytes.Buffer + var exit *exitError + if err := unshare(&out, f, dir); !errors.As(err, &exit) || exit.code != 1 { + t.Fatalf("denied: got %v, want exit 1\n%s", err, out.String()) + } + if _, ok, _ := tailnet.LoadShare(dir); ok { + t.Error("record kept after --off") + } + if !strings.Contains(out.String(), "share --off again") { + t.Errorf("denied output does not ask for the retry:\n%s", out.String()) + } + + f.denied = false + out.Reset() + if err := unshare(&out, f, dir); err != nil { + t.Fatalf("retry: %v\n%s", err, out.String()) + } + if strings.Contains(f.serve, `"443"`) || !strings.Contains(out.String(), "released") { + t.Errorf("retry left the mapping: %s\n%s", f.serve, out.String()) + } + + out.Reset() + if err := unshare(&out, f, dir); err != nil || !strings.Contains(out.String(), "not sharing") { + t.Errorf("third run: err=%v\n%s", err, out.String()) + } +} + +func TestUnshareWithAnUnreadableRecord(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "share.json"), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + f := &fakeServe{serve: `{"TCP":{"443":{"TCPForward":"` + defaultDaemonOptions().listen + `"}}}`} + var out bytes.Buffer + if err := unshare(&out, f, dir); err != nil { + t.Fatalf("%v\n%s", err, out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "share.json")); !os.IsNotExist(err) { + t.Errorf("damaged record left behind: %v", err) + } + if strings.Contains(f.serve, `"443"`) { + t.Errorf("mapping left: %s", f.serve) + } +} + +// Serve mappings persist separately for each logged-in profile. +type profileServe struct { + current string + profiles map[string]*fakeServe + statusErr error + stopped bool +} + +func (f *profileServe) Status(context.Context) (tailnet.Status, error) { + return tailnet.Status{Running: !f.stopped, Tailnet: f.current}, f.statusErr +} +func (f *profileServe) ServeConfig(ctx context.Context) (*tailnet.ServeConfig, error) { + return f.profiles[f.current].ServeConfig(ctx) +} +func (f *profileServe) SetServeConfig(ctx context.Context, cfg *tailnet.ServeConfig) error { + return f.profiles[f.current].SetServeConfig(ctx, cfg) +} + +func TestUnshareRequiresRecordedTailnet(t *testing.T) { + for _, state := range []string{"other tailnet", "unavailable", "stopped"} { + t.Run(state, func(t *testing.T) { + dir := t.TempDir() + record := tailnet.Share{Tailnet: "dreamops.dev", Host: "capella"} + if err := tailnet.SaveShare(dir, record); err != nil { + t.Fatal(err) + } + ours := `{"TCP":{"443":{"TCPForward":"` + defaultDaemonOptions().listen + `"}}}` + f := &profileServe{current: "dreamops.dev", profiles: map[string]*fakeServe{ + "dreamops.dev": {serve: ours}, "client.example": {serve: `{}`}, + }} + switch state { + case "other tailnet": + f.current = "client.example" + case "unavailable": + f.statusErr = tailnet.ErrUnavailable + case "stopped": + f.stopped = true + } + var out bytes.Buffer + if err := unshare(&out, f, dir); err == nil || !strings.Contains(err.Error(), "connect to that tailnet") { + t.Fatalf("got %v, want reconnect instruction", err) + } + if got, ok, err := tailnet.LoadShare(dir); err != nil || !ok || got != record { + t.Fatalf("record lost: %v %v %v", got, ok, err) + } + if f.profiles["dreamops.dev"].serve != ours || f.profiles["client.example"].serve != `{}` { + t.Fatal("changed Serve config before reconnecting") + } + f.current, f.statusErr, f.stopped = "dreamops.dev", nil, false + if err := unshare(&out, f, dir); err != nil { + t.Fatal(err) + } + if strings.Contains(f.profiles["dreamops.dev"].serve, `"443"`) { + t.Fatal("recorded tailnet still forwards after retry") + } + if _, ok, err := tailnet.LoadShare(dir); ok || err != nil { + t.Fatalf("record left: %v %v", ok, err) + } + }) + } +} diff --git a/cmd/grove/exec.go b/cmd/grove/exec.go index e55cd20..6990004 100644 --- a/cmd/grove/exec.go +++ b/cmd/grove/exec.go @@ -243,7 +243,7 @@ func valuesFrom(cfg *config.Config, context identity.Context, allocated map[stri // The hostname a label composes to, and the URL that reaches it. func hostFor(slug, label string) (host, url string) { - host = identity.ComposeLabel(slug, label) + "." + defaultDomain + host = identity.ComposeLabel(slug, label) + "." + zone() return host, "https://" + host } diff --git a/cmd/grove/main.go b/cmd/grove/main.go index e3b336c..bfe7aac 100644 --- a/cmd/grove/main.go +++ b/cmd/grove/main.go @@ -106,6 +106,7 @@ already reads.`, newHoldCommand(), newUninstallCommand(), newLsCommand(), + newShareCommand(), ) return root } diff --git a/cmd/grove/share.go b/cmd/grove/share.go new file mode 100644 index 0000000..bca9a38 --- /dev/null +++ b/cmd/grove/share.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/grove-sh/cli/internal/daemon" + "github.com/grove-sh/cli/internal/platform" + "github.com/grove-sh/cli/internal/tailnet" +) + +const adminDNS = "https://login.tailscale.com/admin/dns" + +func newShareCommand() *cobra.Command { + var stateDir string + var off, yes bool + + cmd := &cobra.Command{ + Use: "share", + Short: "Make this machine's contexts reachable from your other devices on Tailscale", + Long: `Make this machine's contexts reachable from your other devices on Tailscale. + +Sharing ties this machine to the tailnet it is on now. Every context's hostname +gains the machine's name, so app.grov.site becomes app..grov.site, and +the same worktree checked out on two machines is two hostnames. The names stay +that way off the tailnet too, where they still resolve to this machine, so a +running dev server's URL never changes under it. + +While the machine is on that tailnet, the daemon answers DNS for *..grov.site +on its tailnet address and forwards tailnet port 443 to itself through Tailscale +Serve. On any other tailnet it stands down, so joining someone else's does not +offer them your dev servers. It never takes port 443 from another Serve handler; +it says which one to move instead. + +One step is left to you: a split DNS entry in the Tailscale admin console that +sends *..grov.site to this machine. grove share prints it. Answering on +port 53 also means lowering the unprivileged port floor, which is printed in +full and run only when you say so, as grove install does. + +Each device that opens these hostnames has to trust this machine's root. +To stop sharing with --off, connect to the recorded tailnet first. + +Grove needs Tailscale operator access to configure Serve as your user. If it +reports access denied, run sudo tailscale set --operator="$USER", then try again. + +Linux only for now.`, + Example: ` grove share # share on the tailnet this machine is on + grove share --off # stop sharing and go back to .grov.site`, + Args: usageArgs(cobra.NoArgs), + RunE: func(cmd *cobra.Command, _ []string) error { + client := tailnet.New(platform.TailscaleSocket()) + if off { + return unshare(cmd.OutOrStdout(), client, stateDir) + } + return share(cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin(), client, stateDir, yes) + }, + } + cmd.Flags().StringVar(&stateDir, "state-dir", daemon.StateDir(), "directory holding the CA and the share record") + cmd.Flags().BoolVar(&off, "off", false, "stop sharing") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "run the privileged step without asking") + return cmd +} + +func share(out, errOut io.Writer, in io.Reader, client *tailnet.Client, stateDir string, yes bool) error { + paint := styles(out) + if platform.TailscaleSocket() == "" { + return errors.New("sharing on a tailnet is Linux-only for now") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + status, err := client.Status(ctx) + switch { + case errors.Is(err, tailnet.ErrUnavailable): + return errors.New("tailscaled is not answering, so there is no tailnet to share on; check `tailscale status`") + case err != nil: + return err + case !status.Running: + return errors.New("tailscale is not connected; run `tailscale up`, then " + invocation() + " share again") + case status.Tailnet == "" || status.Host == "" || !status.IPv4.IsValid(): + return errors.New("tailscale reports no tailnet name, MagicDNS name, or IPv4 address for this machine; turn on MagicDNS in the admin console") + } + + previous, had, err := tailnet.LoadShare(stateDir) + if err != nil { + return err + } + record := tailnet.Share{Tailnet: status.Tailnet, Host: status.Host} + if err := tailnet.SaveShare(stateDir, record); err != nil { + return err + } + zone := tailnet.Zone(defaultDomain, record, true) + + port443, tone := claimNow(ctx, client) + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "tailnet\t%s\n", status.Tailnet) + fmt.Fprintf(w, "hostnames\thttps://.%s\n", zone) + fmt.Fprintf(w, "address\t%s\n", status.IPv4) + fmt.Fprintf(w, "port 443\t%s\n", paint.paint(tone)(port443)) + w.Flush() + if had && previous.Tailnet != record.Tailnet { + fmt.Fprintf(out, "\n%s\n", paint.warn("moved from "+previous.Tailnet+": this machine shares on one tailnet at a time")) + } + + plan, err := platform.PrepareResolver(stateDir) + if err != nil { + fmt.Fprintf(out, "\n%s\n", paint.bad("grove could not prepare the change to port 53: "+err.Error())) + } else if _, err := elevationFor(yes, in, out, errOut).offer(plan); err != nil { + return err + } + + fmt.Fprintf(out, "\nOne step is yours, once per tailnet, in %s:\n\n", adminDNS) + fmt.Fprintf(out, " Add nameserver > Custom: %s\n", status.IPv4) + fmt.Fprintf(out, " Restrict to domain: %s\n", zone) + if !had || previous != record { + fmt.Fprintf(out, "\n%s\n", paint.warn("Every hostname on this machine has changed. Restart the daemon, then any dev\n"+ + "servers it was serving, so their URLs follow:")) + fmt.Fprintf(out, "\n%s\n", paint.cmd(" "+invocation()+" restart")) + } + return nil +} + +// Claim now so the command reports the result before the daemon starts retrying. +func claimNow(ctx context.Context, client *tailnet.Client) (string, string) { + cfg, err := client.ServeConfig(ctx) + if err != nil { + return "unknown: " + err.Error(), warn + } + changed, err := cfg.Claim443(defaultDaemonOptions().listen) + switch { + case tailnet.IsHeld(err): + return "served by something else; move it to another port (t3code: Settings, Tailscale Serve port) and grove takes it", warn + case err != nil: + return err.Error(), bad + case !changed: + return "forwarded to grove by Tailscale Serve", ok + } + if err := client.SetServeConfig(ctx, cfg); errors.Is(err, tailnet.ErrDenied) { + return "not claimed, since this user may not change Tailscale Serve; run " + tailnet.OperatorFix + " and the daemon claims it", warn + } else if err != nil { + return "not claimed: " + err.Error(), warn + } + return "forwarded to grove by Tailscale Serve", ok +} + +type serveEditor interface { + ServeConfig(context.Context) (*tailnet.ServeConfig, error) + SetServeConfig(context.Context, *tailnet.ServeConfig) error +} + +// An uncertain release must fail: a successful --off must not leave forwarding active. +func releaseNow(ctx context.Context, client serveEditor) (said, tone string, released bool) { + retry := invocation() + " share --off again" + cfg, err := client.ServeConfig(ctx) + if err != nil { + return "cannot tell whether tailnet port 443 still forwards to grove: " + err.Error() + "; once tailscaled answers, run " + retry, bad, false + } + changed, err := cfg.Release443(defaultDaemonOptions().listen) + switch { + case err != nil: + return "cannot tell whether tailnet port 443 still forwards to grove: " + err.Error() + "; run " + retry, bad, false + case !changed: + return "tailnet port 443 was not grove's, so it is left as it was", ok, false + } + err = client.SetServeConfig(ctx, cfg) + switch { + case errors.Is(err, tailnet.ErrDenied): + return "tailnet port 443 still forwards to grove, since this user may not change Tailscale Serve; run " + + tailnet.OperatorFix + ", then " + retry, bad, false + case err != nil: + return "tailnet port 443 still forwards to grove: " + err.Error() + "; run " + retry, bad, false + } + return "tailnet port 443 released", ok, true +} + +type shareEditor interface { + serveEditor + Status(context.Context) (tailnet.Status, error) +} + +// Remove the record first so the daemon stops reclaiming 443, even if release fails. +func unshare(out io.Writer, client shareEditor, stateDir string) error { + paint := styles(out) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + record, had, loadErr := tailnet.LoadShare(stateDir) + if had { + // Releasing another profile would abandon the recorded tailnet's forwarding. + status, err := client.Status(ctx) + if err != nil || !status.Running || status.Tailnet != record.Tailnet { + return fmt.Errorf("sharing is still configured on %s; connect to that tailnet, then run %s share --off again", record.Tailnet, invocation()) + } + } + if had || loadErr != nil { + if err := tailnet.RemoveShare(stateDir); err != nil { + return err + } + } + + released, tone, removed := releaseNow(ctx, client) + + switch { + case !had && loadErr == nil && tone == ok && !removed: + fmt.Fprintln(out, "this machine is not sharing") + return nil + case !had && loadErr == nil: + fmt.Fprintln(out, paint.paint(tone)(released)) + default: + fmt.Fprintf(out, "stopped sharing\n%s\n", paint.paint(tone)(released)) + if had { + fmt.Fprintf(out, "\nThe split DNS entry for %s can go too, in %s.\n", tailnet.Zone(defaultDomain, record, true), adminDNS) + } + fmt.Fprintf(out, "\n%s\n", paint.warn("Hostnames go back to ."+defaultDomain+". Restart the daemon, then any dev\nservers it was serving:")) + fmt.Fprintf(out, "\n%s\n", paint.cmd(" "+invocation()+" restart")) + } + if tone == bad { + return &exitError{code: 1} + } + return nil +} + +// The daemon reports unreadable records at startup; hostname lookups use the default meanwhile. +func zone() string { + record, ok, err := tailnet.LoadShare(daemon.StateDir()) + if err != nil { + return defaultDomain + } + return tailnet.Zone(defaultDomain, record, ok) +} diff --git a/docs/content/concepts/https-and-dns.mdx b/docs/content/concepts/https-and-dns.mdx index 758ad38..9cf2c2a 100644 --- a/docs/content/concepts/https-and-dns.mdx +++ b/docs/content/concepts/https-and-dns.mdx @@ -17,6 +17,10 @@ Some resolvers strip answers that point into loopback as a rebinding defence. Pi The domain isn't a config key. It's a constant, and it's one decision with the address grove serves: a hostname resolving somewhere grove doesn't serve reaches nothing, which is what doctor's DNS check reports when they disagree. +## On a tailnet + +[`grove share`](/reference/cli/share) moves a machine's hostnames one label down, to `..grov.site`, which is the reason leaves are issued per hostname. The public wildcard still answers those names with `127.0.0.4`, so they keep working on the machine itself. On the tailnet, a split DNS entry sends `*..grov.site` to a resolver the daemon runs on the machine's tailnet address instead, and Tailscale Serve forwards tailnet port 443 to grove untouched, so grove still picks the certificate and the route from the hostname. [Share across devices](/guides/tailnet) has the setup. + ## Why 127.0.0.4 Grove serves `127.0.0.4`, not `127.0.0.1`. That's the whole reason it runs beside lando: nothing contends for `127.0.0.1:443`, so neither has to be stopped for the other, and there's no port to hand back. diff --git a/docs/content/guides/meta.ts b/docs/content/guides/meta.ts index 0ec1c5a..a8ddd6a 100644 --- a/docs/content/guides/meta.ts +++ b/docs/content/guides/meta.ts @@ -10,6 +10,7 @@ export default defineMeta({ "dev-server", "no-bind", "ci", + "tailnet", "troubleshooting", ], }); diff --git a/docs/content/guides/tailnet.mdx b/docs/content/guides/tailnet.mdx new file mode 100644 index 0000000..3e5e66f --- /dev/null +++ b/docs/content/guides/tailnet.mdx @@ -0,0 +1,56 @@ +--- +title: Share across devices +description: Open a worktree running on one machine from your laptop or phone over Tailscale, at a hostname that names the machine. +--- + +Grove serves on loopback, so by default only the machine running a worktree can open it. On a tailnet, `grove share` changes that: each machine's contexts get hostnames that name the machine, and every device on the tailnet can open them. + +``` +~/code/app on regulus https://app.regulus.grov.site +~/code/app on capella https://app.capella.grov.site +``` + +The origin is the same from every device, the machine itself included, so cookies, OAuth redirects, and URLs baked into the environment behave the same everywhere. + +Sharing is Linux only for now, and needs MagicDNS turned on for the tailnet. + +## On each machine that serves + +Grove runs as your user and needs Tailscale operator access to configure Serve. If this user is not already the operator, grant access once: + +```sh +sudo tailscale set --operator="$USER" +``` + +Then enable sharing: + +```sh +grove share +grove restart +``` + +`grove share` records the tailnet the machine is on and prints three things: + +- **Port 443.** Grove forwards tailnet port 443 to itself through Tailscale Serve. If another Serve handler already has it, grove leaves it alone and says so. t3code's Tailscale Serve setting is the usual one: change its port, to 8443 for example, and grove takes 443 within ten seconds. +- **Port 53.** The daemon answers DNS for the machine's hostnames on its tailnet address, which means lowering the unprivileged port floor to 53. It's the same file `grove install` wrote, printed in full and run when you say so. +- **The split DNS entry.** In the [Tailscale admin console](https://login.tailscale.com/admin/dns), choose Add nameserver, then Custom. Enter the address it printed and turn on Restrict to domain with `.grov.site`. Every plan can do this, the free one included. + +The restart matters. Every hostname on the machine changes, so the daemon and any dev servers it was serving need starting again for their URLs to follow. + +## On each device that opens them + +Every device needs two things. The first is **Use Tailscale DNS** turned on in the Tailscale app, or `--accept-dns` on Linux, which is the default. Without it the split DNS entry never applies. If the device uses an exit node, also turn on Use with exit node for each entry. + +The second is **the serving machine's root, trusted**. Each machine has its own, at `root.crt` in its state directory, `~/.local/state/grove` on Linux. Copy it over and install it as a CA certificate: into the system trust store on a computer, or on Android through Settings, Security, Encryption & credentials, Install a certificate, CA certificate. Only the `.crt` travels, never the key. + +## Switching tailnets + +A machine shares on one tailnet. On any other, or with Tailscale disconnected, the daemon closes its resolver and takes its port 443 mapping down, so a client's tailnet never sees your dev servers. Hostnames keep the machine's name throughout and resolve to the machine itself, so local work carries on. Back on the shared tailnet, sharing resumes on its own. + +To share on a different tailnet, run `grove share` while on it. + +## Checking it + +`grove doctor` reports a Sharing line on any machine that shares. It names whichever part is missing: Tailscale not connected, nothing answering DNS, the split DNS entry absent, or port 443 held by something else. + +To stop, connect to the tailnet recorded by `grove share`, run `grove share --off`, then `grove restart`, and remove the split DNS entry. diff --git a/docs/content/guides/troubleshooting.mdx b/docs/content/guides/troubleshooting.mdx index 73fa1d9..ec298fb 100644 --- a/docs/content/guides/troubleshooting.mdx +++ b/docs/content/guides/troubleshooting.mdx @@ -43,6 +43,7 @@ Each check reports on its own, and a failure names the fix. A failure exits non- | DNS | Whether `*.grov.site` resolves here, and to loopback. Quiet when it does. | | Port 443 | Whether 443 reaches grove, and who holds it if not. Quiet when it does. | | HTTP redirect | Whether port 80 redirects to https. Quiet when it does. | +| Sharing | On a machine that ran `grove share`: whether it's on the shared tailnet, answering DNS there, reached through split DNS, and holding tailnet port 443. Absent on a machine that doesn't share. | | Main worktree | Which worktree of a bare repository is the project itself, and what decided it. | | Context | Whether this worktree is holding ports under one name while resolving another. | | Project grove | Which grove the project installs, and whether packages disagree. | diff --git a/docs/content/reference/cli/index.mdx b/docs/content/reference/cli/index.mdx index 4ca40e0..057c438 100644 --- a/docs/content/reference/cli/index.mdx +++ b/docs/content/reference/cli/index.mdx @@ -9,6 +9,7 @@ sidebar: grove init write a grove.toml for this project grove install set this machine up: authority, trust, port 443 grove uninstall stop grove and untrust its root +grove share [--off] reach this machine's contexts from your other devices grove exec [-s ] -- run a command in this context grove env [--format shell|json] print that environment instead of running grove ls [--all] this context's routes, or every lease on the machine @@ -16,7 +17,7 @@ grove hold take this context's detached ports grove release [name...] let them go grove forget [context...] drop the records of worktrees that are gone grove context [--json] how this directory resolves -grove doctor DNS, trust, the daemon, and port 443 +grove doctor DNS, trust, the daemon, port 443, and sharing grove start | stop | restart the daemon, which is one process for the machine ``` @@ -28,7 +29,7 @@ grove start | stop | restart the daemon, which is one process for the machin |------|---------| | `--socket` | The control socket to reach the daemon on. Defaults to `GROVE_SOCKET` or the platform path. | | `--state-dir` | The directory holding the CA. Defaults to `GROVE_STATE_DIR` or the platform path. | -| `-y`, `--yes` | On `install` and `uninstall`: run the privileged step without asking. | +| `-y`, `--yes` | On `install`, `uninstall`, and `share`: run the privileged step without asking. | | `--force` | On `stop` and `uninstall`: go ahead while a running command is served. On `init`: overwrite an existing file. | ## Exit codes diff --git a/docs/content/reference/cli/meta.ts b/docs/content/reference/cli/meta.ts index cb6571f..9a7dc9e 100644 --- a/docs/content/reference/cli/meta.ts +++ b/docs/content/reference/cli/meta.ts @@ -6,6 +6,7 @@ export default defineMeta({ "init", "install", "uninstall", + "share", "exec", "env", "ls", diff --git a/docs/content/reference/cli/share.mdx b/docs/content/reference/cli/share.mdx new file mode 100644 index 0000000..abd853a --- /dev/null +++ b/docs/content/reference/cli/share.mdx @@ -0,0 +1,40 @@ +--- +title: grove share +description: Make this machine's contexts reachable from your other devices on Tailscale, or stop with --off. +--- + +```sh +grove share [--off] [--yes] [--state-dir ] +``` + +Make this machine's contexts reachable from your other devices on Tailscale. + +Sharing ties this machine to the tailnet it is on now. Every context's hostname gains the machine's name, so `app.grov.site` becomes `app..grov.site`, and the same worktree checked out on two machines is two hostnames. The names stay that way off the tailnet too, where they still resolve to this machine, so a running dev server's URL never changes under it. + +While the machine is on that tailnet, the daemon answers DNS for `*..grov.site` on its tailnet address and forwards tailnet port 443 to itself through Tailscale Serve. On any other tailnet it stands down, so joining someone else's does not offer them your dev servers. It never takes port 443 from another Serve handler; it says which one to move instead. + +One step is left to you: a split DNS entry in the Tailscale admin console that sends `*..grov.site` to this machine. `grove share` prints it. Answering on port 53 also means lowering the unprivileged port floor, which is printed in full and run only when you say so, as [`grove install`](/reference/cli/install) does. + +Each device that opens these hostnames has to trust this machine's root. +To stop sharing with `--off`, connect to the recorded tailnet first. + +Grove needs Tailscale operator access to configure Serve as your user. If it reports access denied, run `sudo tailscale set --operator="$USER"`, then try again. + +Linux only for now. [Share across devices](/guides/tailnet) walks through the whole setup. + +## Flags + +| Flag | Meaning | +|------|---------| +| `--off` | Stop sharing. Connect to the recorded tailnet first; otherwise the command fails and keeps the record so you can retry. Hostnames go back to `.grov.site`, and grove's tailnet port 443 mapping is removed. Anyone else's is left alone. It exits 1 if the mapping might still be there, and running it again finishes the job, with or without a share record. The running daemon stops sharing within ten seconds. | +| `-y`, `--yes` | Run the privileged step without asking. | +| `--state-dir` | The directory holding the CA and the share record, `share.json`. | + +## Examples + +```sh +grove share # share on the tailnet this machine is on +grove share --off # stop sharing and go back to .grov.site +``` + +Either way, run `grove restart` afterwards, then any dev servers it was serving, so their URLs follow. diff --git a/internal/daemon/server.go b/internal/daemon/server.go index edee017..2f22d1d 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -18,10 +18,13 @@ import ( "github.com/grove-sh/cli/internal/identity" "github.com/grove-sh/cli/internal/lease" "github.com/grove-sh/cli/internal/proxy" + "github.com/grove-sh/cli/internal/tailnet" ) type Config struct { Domain string + // Empty uses Domain, including for callers that do not configure sharing. + Zone string // The authority, and the record of where detached ports landed. CADir string @@ -30,10 +33,18 @@ type Config struct { // Reported to clients, so they can say when the running daemon is not the // grove asking. Version string + + Share *tailnet.Share + Tailscale Tailscale + Target string + Logf func(string, ...any) + // Zero uses port 53; tests can bind an unprivileged port. + DNSPort int } type Server struct { domain string + zone string registry *lease.Registry // Held apart from the registry, which allocates out of the record and has @@ -47,6 +58,8 @@ type Server struct { listen string redirect *nethttp.Server started time.Time + sharing *sharing + unshare context.CancelFunc mu sync.Mutex control net.Listener @@ -83,8 +96,28 @@ func New(cfg Config) (*Server, error) { if err != nil { return nil, err } + var share *sharing + if cfg.Share != nil && cfg.Tailscale != nil { + share = &sharing{ + api: cfg.Tailscale, + share: *cfg.Share, + zone: zoneOr(cfg.Zone, cfg.Domain), + target: cfg.Target, + stateDir: cfg.CADir, + logf: cfg.Logf, + dnsPort: cfg.DNSPort, + } + if share.logf == nil { + share.logf = func(string, ...any) {} + } + if share.dnsPort == 0 { + share.dnsPort = 53 + } + } return &Server{ + sharing: share, domain: cfg.Domain, + zone: zoneOr(cfg.Zone, cfg.Domain), version: cfg.Version, registry: registry, memory: memory, @@ -150,6 +183,13 @@ func (s *Server) Serve(control, https, http net.Listener) error { if redirect != nil { go func() { failed <- redirect.Serve(http) }() } + if s.sharing != nil { + ctx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.unshare = cancel + s.mu.Unlock() + go s.sharing.run(ctx) + } Ready() @@ -171,12 +211,16 @@ func (s *Server) Shutdown() { s.closing = true control := s.control redirect := s.redirect + unshare := s.unshare conns := make([]net.Conn, 0, len(s.conns)) for conn := range s.conns { conns = append(conns, conn) } s.mu.Unlock() + if unshare != nil { + unshare() + } if control != nil { control.Close() } @@ -498,7 +542,14 @@ func (s *Server) hosts(slug string, entry Entry) (string, []string) { } func (s *Server) host(slug, label string) string { - return identity.ComposeLabel(slug, label) + "." + s.domain + return identity.ComposeLabel(slug, label) + "." + s.zone +} + +func zoneOr(zone, domain string) string { + if zone == "" { + return domain + } + return zone } // toHTTPS sends a plain request to the same URL over TLS, which is the only diff --git a/internal/daemon/sharing.go b/internal/daemon/sharing.go new file mode 100644 index 0000000..a1664a0 --- /dev/null +++ b/internal/daemon/sharing.go @@ -0,0 +1,169 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "sync" + "time" + + "github.com/grove-sh/cli/internal/tailnet" +) + +// Bound the delay before a tailnet switch closes the resolver on the old address. +const shareInterval = 10 * time.Second + +type Tailscale interface { + Status(context.Context) (tailnet.Status, error) + ServeConfig(context.Context) (*tailnet.ServeConfig, error) + SetServeConfig(context.Context, *tailnet.ServeConfig) error +} + +type sharing struct { + api Tailscale + share tailnet.Share + zone string + target string + // Re-read each tick so --off stops reclamation without waiting for a restart. + stateDir string + logf func(string, ...any) + dnsPort int + + mu sync.Mutex + resolver net.PacketConn + bound netip.Addr + // Suppress repeated log entries while the sharing state is unchanged. + said string +} + +func (s *sharing) run(ctx context.Context) { + ticker := time.NewTicker(shareInterval) + defer ticker.Stop() + for { + s.tick(ctx) + select { + case <-ctx.Done(): + s.stopResolver() + return + case <-ticker.C: + } + } +} + +func (s *sharing) tick(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if record, ok, err := tailnet.LoadShare(s.stateDir); err != nil || !ok || record != s.share { + s.stopResolver() + s.release(ctx) + s.say("sharing stopped: the share record changed since this daemon started; restart it so hostnames follow") + return + } + + status, err := s.api.Status(ctx) + switch { + case err != nil: + s.stopResolver() + s.say("sharing paused: %v", err) + return + case !status.Running: + s.stopResolver() + s.say("sharing paused: tailscale is not connected") + return + case status.Tailnet != s.share.Tailnet: + s.stopResolver() + s.release(ctx) + s.say("sharing paused: on tailnet %s, and this machine shares on %s", status.Tailnet, s.share.Tailnet) + return + case !status.IPv4.IsValid(): + s.stopResolver() + s.say("sharing paused: tailscale reports no IPv4 address yet") + return + } + + if err := s.bindResolver(status); err != nil { + s.say("sharing: cannot answer DNS on %s: %v", net.JoinHostPort(status.IPv4.String(), strconv.Itoa(s.dnsPort)), err) + return + } + if err := s.claim(ctx); err != nil { + s.say("sharing: %v", err) + return + } + s.say("sharing *.%s on %s", s.zone, status.IPv4) +} + +func (s *sharing) bindResolver(status tailnet.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.resolver != nil && s.bound == status.IPv4 { + return nil + } + if s.resolver != nil { + s.resolver.Close() + s.resolver = nil + } + conn, err := net.ListenPacket("udp", net.JoinHostPort(status.IPv4.String(), strconv.Itoa(s.dnsPort))) + if err != nil { + return err + } + s.resolver, s.bound = conn, status.IPv4 + go tailnet.Resolver{Zone: s.zone, IPv4: status.IPv4, IPv6: status.IPv6}.Serve(conn) + return nil +} + +func (s *sharing) stopResolver() { + s.mu.Lock() + defer s.mu.Unlock() + if s.resolver != nil { + s.resolver.Close() + s.resolver = nil + s.bound = netip.Addr{} + } +} + +func (s *sharing) claim(ctx context.Context) error { + cfg, err := s.api.ServeConfig(ctx) + if err != nil { + return err + } + changed, err := cfg.Claim443(s.target) + if tailnet.IsHeld(err) { + return errors.New("tailnet port 443 is served by something else, so other devices cannot reach grove; " + + "move it to another port (t3code: Settings, Tailscale Serve port) and grove takes 443 on its next check") + } + if err != nil || !changed { + return err + } + err = s.api.SetServeConfig(ctx, cfg) + if errors.Is(err, tailnet.ErrDenied) { + return errors.New("this user may not change Tailscale Serve, so tailnet port 443 stays unclaimed; run " + + tailnet.OperatorFix + " and grove claims it on its next check") + } + return err +} + +// Remove any visible forward to grove rather than exposing dev servers on another tailnet. +func (s *sharing) release(ctx context.Context) { + cfg, err := s.api.ServeConfig(ctx) + if err != nil { + return + } + if changed, err := cfg.Release443(s.target); err == nil && changed { + s.api.SetServeConfig(ctx, cfg) + } +} + +func (s *sharing) say(format string, args ...any) { + s.mu.Lock() + defer s.mu.Unlock() + msg := fmt.Sprintf(format, args...) + if msg == s.said { + return + } + s.said = msg + s.logf("%s", msg) +} diff --git a/internal/daemon/sharing_test.go b/internal/daemon/sharing_test.go new file mode 100644 index 0000000..7b2ad6b --- /dev/null +++ b/internal/daemon/sharing_test.go @@ -0,0 +1,222 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/grove-sh/cli/internal/tailnet" +) + +type fakeTailscale struct { + mu sync.Mutex + status tailnet.Status + serve string + writes int + denied bool +} + +func (f *fakeTailscale) Status(context.Context) (tailnet.Status, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.status, nil +} + +func (f *fakeTailscale) ServeConfig(context.Context) (*tailnet.ServeConfig, error) { + f.mu.Lock() + defer f.mu.Unlock() + return tailnet.ParseServeConfig([]byte(f.serve), "") +} + +func (f *fakeTailscale) SetServeConfig(_ context.Context, cfg *tailnet.ServeConfig) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.denied { + return tailnet.ErrDenied + } + raw, err := cfg.MarshalJSON() + f.serve = string(raw) + f.writes++ + return err +} + +func (f *fakeTailscale) set(fn func(*fakeTailscale)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(f) +} + +func freeUDPPort(t *testing.T) int { + t.Helper() + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).Port +} + +// Use loopback so these tests need no Tailscale interface. +func newSharing(t *testing.T, f *fakeTailscale) (*sharing, *[]string) { + t.Helper() + var logged []string + share := tailnet.Share{Tailnet: "dreamops.dev", Host: "capella"} + dir := t.TempDir() + if err := tailnet.SaveShare(dir, share); err != nil { + t.Fatal(err) + } + s := &sharing{ + api: f, + share: share, + stateDir: dir, + zone: "capella.grov.site", + target: "127.0.0.4:443", + dnsPort: freeUDPPort(t), + logf: func(format string, args ...any) { + logged = append(logged, fmt.Sprintf(format, args...)) + }, + } + t.Cleanup(s.stopResolver) + return s, &logged +} + +func onTailnet(name string) tailnet.Status { + return tailnet.Status{Running: true, Tailnet: name, Host: "capella", IPv4: netip.MustParseAddr("127.0.0.1")} +} + +func lookup(t *testing.T, port int, name string) ([]netip.Addr, error) { + t.Helper() + r := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + }} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return r.LookupNetIP(ctx, "ip4", name) +} + +func TestSharingAnswersAndClaimsOnItsTailnet(t *testing.T) { + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: `{"TCP":{"8443":{"HTTPS":true}}}`} + s, _ := newSharing(t, f) + s.tick(context.Background()) + + addrs, err := lookup(t, s.dnsPort, "app.capella.grov.site") + if err != nil || len(addrs) != 1 || addrs[0].String() != "127.0.0.1" { + t.Fatalf("lookup: %v %v", addrs, err) + } + if !strings.Contains(f.serve, `"443":{"TCPForward":"127.0.0.4:443"}`) || !strings.Contains(f.serve, `"8443"`) { + t.Errorf("serve config %s", f.serve) + } + + s.tick(context.Background()) + if f.writes != 1 { + t.Errorf("%d writes after two ticks, want 1: an unchanged claim writes nothing", f.writes) + } +} + +func TestSharingStandsDownOnAnotherTailnet(t *testing.T) { + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: `{}`} + s, logged := newSharing(t, f) + s.tick(context.Background()) + + f.set(func(f *fakeTailscale) { f.status = onTailnet("client.example") }) + s.tick(context.Background()) + + if _, err := lookup(t, s.dnsPort, "app.capella.grov.site"); err == nil { + t.Error("resolver still answering on another tailnet") + } + if strings.Contains(f.serve, `"443"`) { + t.Errorf("grove's mapping left in place: %s", f.serve) + } + if last := (*logged)[len(*logged)-1]; !strings.Contains(last, "client.example") { + t.Errorf("last log %q does not name the tailnet it stood down on", last) + } + + f.set(func(f *fakeTailscale) { f.status = onTailnet("dreamops.dev") }) + s.tick(context.Background()) + if _, err := lookup(t, s.dnsPort, "app.capella.grov.site"); err != nil { + t.Errorf("not answering after switching back: %v", err) + } +} + +func TestSharingNeverTakesAPortSomeoneElseServes(t *testing.T) { + const t3 = `{"TCP":{"443":{"HTTPS":true}}}` + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: t3} + s, logged := newSharing(t, f) + s.tick(context.Background()) + s.tick(context.Background()) + + if f.serve != t3 || f.writes != 0 { + t.Errorf("wrote %d times, config now %s", f.writes, f.serve) + } + held := 0 + for _, line := range *logged { + if strings.Contains(line, "served by something else") { + held++ + } + } + if held != 1 { + t.Errorf("held port reported %d times over two ticks, want once: %q", held, *logged) + } + if _, err := lookup(t, s.dnsPort, "app.capella.grov.site"); err != nil { + t.Errorf("resolver down while 443 is held: %v", err) + } +} + +func TestSharingPausesWhileDisconnected(t *testing.T) { + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: `{}`} + s, _ := newSharing(t, f) + s.tick(context.Background()) + + f.set(func(f *fakeTailscale) { f.status = tailnet.Status{} }) + s.tick(context.Background()) + if _, err := lookup(t, s.dnsPort, "app.capella.grov.site"); err == nil { + t.Error("resolver still answering while tailscale is down") + } +} + +func TestSharingNamesTheOperatorFixWhenDenied(t *testing.T) { + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: `{}`, denied: true} + s, logged := newSharing(t, f) + s.tick(context.Background()) + + last := (*logged)[len(*logged)-1] + if !strings.Contains(last, tailnet.OperatorFix) || !strings.Contains(last, "next check") { + t.Errorf("logged %q", last) + } + if _, err := lookup(t, s.dnsPort, "app.capella.grov.site"); err != nil { + t.Errorf("resolver down while Serve is denied: %v", err) + } +} + +// Removing the record must prevent the daemon from reclaiming the released port. +func TestSharingStopsWhenTheRecordGoes(t *testing.T) { + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: `{}`} + s, logged := newSharing(t, f) + s.tick(context.Background()) + if !strings.Contains(f.serve, `"443"`) { + t.Fatalf("not claimed to begin with: %s", f.serve) + } + + if err := tailnet.RemoveShare(s.stateDir); err != nil { + t.Fatal(err) + } + s.tick(context.Background()) + s.tick(context.Background()) + + if strings.Contains(f.serve, `"443"`) { + t.Errorf("claimed again after the record went: %s", f.serve) + } + if _, err := lookup(t, s.dnsPort, "app.capella.grov.site"); err == nil { + t.Error("resolver still answering after the record went") + } + if last := (*logged)[len(*logged)-1]; !strings.Contains(last, "restart") { + t.Errorf("last log %q does not say to restart", last) + } +} diff --git a/internal/platform/platform_darwin.go b/internal/platform/platform_darwin.go index 06df604..496fe73 100644 --- a/internal/platform/platform_darwin.go +++ b/internal/platform/platform_darwin.go @@ -156,3 +156,7 @@ func WSL() bool { return false } // The same arrangement for 80, out of the same anchor. func DefaultHTTPListen() string { return fmt.Sprintf("%s:%d", Address, redirect.HTTPPort) } + +func TailscaleSocket() string { return "" } + +func PrepareResolver(string) (Plan, error) { return Plan{}, nil } diff --git a/internal/platform/platform_linux.go b/internal/platform/platform_linux.go index 059cde6..7480547 100644 --- a/internal/platform/platform_linux.go +++ b/internal/platform/platform_linux.go @@ -59,6 +59,15 @@ func WSL() bool { return strings.Contains(strings.ToLower(string(release)), "microsoft") } +var portsPlan = Plan{ + Summary: "the floor is above 80, so grove cannot bind 443 or 80 as you", + Intro: "Lowering the unprivileged port floor lets grove bind 443 and 80 as you, so the\n" + + "daemon never runs as root. The file is written; installing it is one privileged\n" + + "step:", + Outro: "80 rather than 443 so grove can also answer plain http and send it to https.\n" + + "The file is one line, and the machine reads it again at every boot.", +} + // A floor already at 80 leaves nothing to do; one at 443 still costs the http // redirect, so it is offered the same file. func PreparePorts(dir string) (Plan, error) { @@ -66,32 +75,45 @@ func PreparePorts(dir string) (Plan, error) { if access.Allowed && access.Advice == "" { return Plan{}, nil } - return lowerFloor(dir) + return lowerFloor(dir, 80, portsPlan) +} + +func TailscaleSocket() string { return "/var/run/tailscale/tailscaled.sock" } + +var resolverPlan = Plan{ + Summary: "the floor is above 53, so grove cannot answer DNS for your tailnet as you", + Intro: "Tailscale's split DNS sends this host's names to a resolver on port 53, which\n" + + "grove runs on its tailnet address. Lowering the floor to 53 lets it do that as\n" + + "you. The file is written; installing it is one privileged step:", + Outro: "It replaces the floor grove install set, in the same file, and the machine\n" + + "reads it again at every boot.", } -// Staged as a file and copied rather than written through tee, so the reader -// can open what root is about to be handed, and so the step is one command -// with no pipe in it. -func lowerFloor(dir string) (Plan, error) { +// Reuse the install sysctl file so uninstall also removes the DNS port change. +func PrepareResolver(dir string) (Plan, error) { + floor, err := os.ReadFile(unprivilegedPortStart) + if err == nil { + if start, err := strconv.Atoi(strings.TrimSpace(string(floor))); err == nil && start <= 53 { + return Plan{}, nil + } + } + return lowerFloor(dir, 53, resolverPlan) +} + +// Stage the file so the user can inspect it before the privileged copy. +func lowerFloor(dir string, floor int, plan Plan) (Plan, error) { staged := filepath.Join(dir, "sysctl", filepath.Base(sysctlFile)) if err := os.MkdirAll(filepath.Dir(staged), 0o755); err != nil { return Plan{}, err } - if err := os.WriteFile(staged, []byte(sysctlKey+"=80\n"), 0o644); err != nil { + if err := os.WriteFile(staged, []byte(sysctlKey+"="+strconv.Itoa(floor)+"\n"), 0o644); err != nil { return Plan{}, err } - return Plan{ - Summary: "the floor is above 80, so grove cannot bind 443 or 80 as you", - Intro: "Lowering the unprivileged port floor lets grove bind 443 and 80 as you, so the\n" + - "daemon never runs as root. The file is written; installing it is one privileged\n" + - "step:", - Steps: []shell.Step{ - {"cp", staged, sysctlFile}, - {"sysctl", "-p", sysctlFile}, - }, - Outro: "80 rather than 443 so grove can also answer plain http and send it to https.\n" + - "The file is one line, and the machine reads it again at every boot.", - }, nil + plan.Steps = []shell.Step{ + {"cp", staged, sysctlFile}, + {"sysctl", "-p", sysctlFile}, + } + return plan, nil } // Only grove's own file is taken back. A floor lowered some other way is the diff --git a/internal/platform/platform_linux_test.go b/internal/platform/platform_linux_test.go index c65e0cf..539a637 100644 --- a/internal/platform/platform_linux_test.go +++ b/internal/platform/platform_linux_test.go @@ -12,7 +12,7 @@ import ( func TestLowerFloorStagesTheFileItInstalls(t *testing.T) { dir := t.TempDir() - plan, err := lowerFloor(dir) + plan, err := lowerFloor(dir, 80, portsPlan) if err != nil { t.Fatal(err) } @@ -41,3 +41,25 @@ func TestLowerFloorStagesTheFileItInstalls(t *testing.T) { t.Errorf("plan is missing its prose: %+v", plan) } } + +func TestResolverFloorRewritesTheSameFile(t *testing.T) { + dir := t.TempDir() + plan, err := lowerFloor(dir, 53, resolverPlan) + if err != nil { + t.Fatal(err) + } + staged := filepath.Join(dir, "sysctl", "60-grove.conf") + body, err := os.ReadFile(staged) + if err != nil { + t.Fatal(err) + } + if string(body) != "net.ipv4.ip_unprivileged_port_start=53\n" { + t.Errorf("staged %q", body) + } + if len(plan.Steps) != 2 || plan.Steps[0].String() != "cp "+staged+" "+sysctlFile { + t.Errorf("steps %v", plan.Steps) + } + if plan.Summary == "" || !strings.Contains(plan.Intro, "port 53") { + t.Errorf("plan text: %+v", plan) + } +} diff --git a/internal/platform/platform_other.go b/internal/platform/platform_other.go index ff65d33..e66b331 100644 --- a/internal/platform/platform_other.go +++ b/internal/platform/platform_other.go @@ -24,3 +24,7 @@ func DefaultListen() string { return Address + ":443" } // Best effort, like 443: nothing breaks when it fails. func DefaultHTTPListen() string { return Address + ":80" } + +func TailscaleSocket() string { return "" } + +func PrepareResolver(string) (Plan, error) { return Plan{}, nil } diff --git a/internal/tailnet/dns.go b/internal/tailnet/dns.go new file mode 100644 index 0000000..cdf7efa --- /dev/null +++ b/internal/tailnet/dns.go @@ -0,0 +1,119 @@ +package tailnet + +import ( + "encoding/binary" + "errors" + "net" + "net/netip" + "strings" +) + +const ( + typeA = 1 + typeAAAA = 28 + classIN = 1 + + rcodeOK = 0 + rcodeFormErr = 1 + rcodeNXDomain = 3 + + answerTTL = 30 +) + +// Refuse names outside the split DNS zone so peers cannot use this as a recursive resolver. +type Resolver struct { + Zone string + IPv4 netip.Addr + IPv6 netip.Addr +} + +func (r Resolver) Serve(conn net.PacketConn) error { + buf := make([]byte, 512) + for { + n, addr, err := conn.ReadFrom(buf) + if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } + return err + } + if reply := r.Answer(buf[:n]); reply != nil { + conn.WriteTo(reply, addr) + } + } +} + +func (r Resolver) Answer(query []byte) []byte { + if len(query) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(query[2:4]) + if flags&0x8000 != 0 { + return nil + } + if binary.BigEndian.Uint16(query[4:6]) != 1 { + return header(query, flags, rcodeFormErr, 0) + } + name, end, ok := readName(query, 12) + if !ok || end+4 > len(query) { + return header(query, flags, rcodeFormErr, 0) + } + qtype := binary.BigEndian.Uint16(query[end : end+2]) + question := query[12 : end+4] + + zone := strings.ToLower(strings.TrimSuffix(r.Zone, ".")) + if name != zone && !strings.HasSuffix(name, "."+zone) { + return append(header(query, flags, rcodeNXDomain, 0), question...) + } + + var addr netip.Addr + switch qtype { + case typeA: + addr = r.IPv4 + case typeAAAA: + addr = r.IPv6 + } + // NXDOMAIN would also suppress valid records of other types for this name. + if !addr.IsValid() { + return append(header(query, flags, rcodeOK, 0), question...) + } + out := append(header(query, flags, rcodeOK, 1), question...) + out = append(out, 0xc0, 0x0c) + out = binary.BigEndian.AppendUint16(out, qtype) + out = binary.BigEndian.AppendUint16(out, classIN) + out = binary.BigEndian.AppendUint32(out, answerTTL) + ip := addr.AsSlice() + out = binary.BigEndian.AppendUint16(out, uint16(len(ip))) + return append(out, ip...) +} + +func header(query []byte, flags uint16, rcode uint16, answers uint16) []byte { + out := make([]byte, 12) + copy(out[0:2], query[0:2]) + binary.BigEndian.PutUint16(out[2:4], 0x8400|flags&0x0100|rcode) + questions := uint16(1) + if rcode == rcodeFormErr { + questions = 0 + } + binary.BigEndian.PutUint16(out[4:6], questions) + binary.BigEndian.PutUint16(out[6:8], answers) + return out +} + +func readName(msg []byte, offset int) (string, int, bool) { + var labels []string + for { + if offset >= len(msg) { + return "", 0, false + } + length := int(msg[offset]) + if length == 0 { + return strings.ToLower(strings.Join(labels, ".")), offset + 1, true + } + if length > 63 || offset+1+length > len(msg) { + return "", 0, false + } + labels = append(labels, string(msg[offset+1:offset+1+length])) + offset += 1 + length + } +} diff --git a/internal/tailnet/dns_test.go b/internal/tailnet/dns_test.go new file mode 100644 index 0000000..bffbeee --- /dev/null +++ b/internal/tailnet/dns_test.go @@ -0,0 +1,95 @@ +package tailnet_test + +import ( + "context" + "errors" + "net" + "net/netip" + "testing" + "time" + + "github.com/grove-sh/cli/internal/tailnet" +) + +func resolverAt(t *testing.T, r tailnet.Resolver) *net.Resolver { + t.Helper() + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + go r.Serve(conn) + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp", conn.LocalAddr().String()) + }, + } +} + +func TestResolverAnswersEveryNameInTheZone(t *testing.T) { + r := resolverAt(t, tailnet.Resolver{ + Zone: "capella.grov.site", + IPv4: netip.MustParseAddr("100.76.35.70"), + IPv6: netip.MustParseAddr("fd7a:115c:a1e0::2039:2347"), + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for _, name := range []string{"app.capella.grov.site", "APP-feat.Capella.grov.site", "capella.grov.site"} { + addrs, err := r.LookupNetIP(ctx, "ip", name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + got := map[string]bool{} + for _, a := range addrs { + got[a.String()] = true + } + if !got["100.76.35.70"] || !got["fd7a:115c:a1e0::2039:2347"] { + t.Errorf("%s resolved to %v, want both tailnet addresses", name, addrs) + } + } +} + +func TestResolverRefusesNamesOutsideTheZone(t *testing.T) { + r := resolverAt(t, tailnet.Resolver{Zone: "capella.grov.site", IPv4: netip.MustParseAddr("100.76.35.70")}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for _, name := range []string{"example.com", "regulus.grov.site", "app.notcapella.grov.site"} { + _, err := r.LookupNetIP(ctx, "ip4", name) + var dnsErr *net.DNSError + if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound { + t.Errorf("%s: got %v, want not found", name, err) + } + } +} + +func TestResolverAnswersEmptyForAMissingFamily(t *testing.T) { + r := resolverAt(t, tailnet.Resolver{Zone: "capella.grov.site", IPv4: netip.MustParseAddr("100.76.35.70")}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + addrs, err := r.LookupNetIP(ctx, "ip", "app.capella.grov.site") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0].String() != "100.76.35.70" { + t.Errorf("got %v, want only the IPv4 address", addrs) + } +} + +func TestResolverIgnoresGarbage(t *testing.T) { + r := tailnet.Resolver{Zone: "capella.grov.site", IPv4: netip.MustParseAddr("100.76.35.70")} + for _, packet := range [][]byte{nil, {1, 2, 3}, make([]byte, 11)} { + if reply := r.Answer(packet); reply != nil { + t.Errorf("answered %v", packet) + } + } + truncated := []byte{0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 7, 'c', 'a', 'p'} + reply := r.Answer(truncated) + if reply == nil || reply[3]&0x0f != 1 { + t.Errorf("truncated name: got %v, want FORMERR", reply) + } +} diff --git a/internal/tailnet/share.go b/internal/tailnet/share.go new file mode 100644 index 0000000..37f9da2 --- /dev/null +++ b/internal/tailnet/share.go @@ -0,0 +1,65 @@ +package tailnet + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const shareFile = "share.json" + +// Keep the recorded tailnet and host stable so disconnecting cannot change running servers' URLs. +type Share struct { + Tailnet string `json:"tailnet"` + Host string `json:"host"` +} + +func LoadShare(dir string) (share Share, ok bool, err error) { + raw, err := os.ReadFile(filepath.Join(dir, shareFile)) + if errors.Is(err, os.ErrNotExist) { + return Share{}, false, nil + } + if err != nil { + return Share{}, false, err + } + if err := json.Unmarshal(raw, &share); err != nil { + return Share{}, false, fmt.Errorf("tailnet: %s is not a share record: %w", filepath.Join(dir, shareFile), err) + } + if share.Tailnet == "" || share.Host == "" { + return Share{}, false, fmt.Errorf("tailnet: %s names no tailnet or host; run grove share again", filepath.Join(dir, shareFile)) + } + return share, true, nil +} + +func SaveShare(dir string, share Share) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(share, "", " ") + if err != nil { + return err + } + // Rename atomically so the daemon cannot read a partially written record. + tmp := filepath.Join(dir, shareFile+".tmp") + if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(dir, shareFile)) +} + +func RemoveShare(dir string) error { + err := os.Remove(filepath.Join(dir, shareFile)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func Zone(domain string, share Share, ok bool) string { + if !ok { + return domain + } + return share.Host + "." + domain +} diff --git a/internal/tailnet/tailnet.go b/internal/tailnet/tailnet.go new file mode 100644 index 0000000..ca644e8 --- /dev/null +++ b/internal/tailnet/tailnet.go @@ -0,0 +1,255 @@ +package tailnet + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "strings" + "time" +) + +var ErrUnavailable = errors.New("tailnet: tailscaled is not reachable") + +// Linux permits Serve writes only for root and the configured operator. +var ErrDenied = errors.New("tailnet: this user may not change Tailscale Serve") + +const OperatorFix = `sudo tailscale set --operator="$USER"` + +var ErrChanged = errors.New("tailnet: the Serve config changed underneath grove") + +type Status struct { + Running bool + Tailnet string + // Use the MagicDNS label so split DNS and host-qualified names agree. + Host string + IPv4 netip.Addr + IPv6 netip.Addr +} + +type Client struct { + http *http.Client +} + +func New(socket string) *Client { + if socket == "" { + return &Client{} + } + dialer := net.Dialer{Timeout: 2 * time.Second} + return &Client{http: &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "unix", socket) + }, + }, + }} +} + +func (c *Client) Status(ctx context.Context) (Status, error) { + var raw struct { + BackendState string + CurrentTailnet *struct{ Name string } + Self *struct { + DNSName string + TailscaleIPs []string + } + } + body, _, err := c.get(ctx, "status") + if err != nil { + return Status{}, err + } + if err := json.Unmarshal(body, &raw); err != nil { + return Status{}, fmt.Errorf("tailnet: reading status: %w", err) + } + s := Status{Running: raw.BackendState == "Running"} + if raw.CurrentTailnet != nil { + s.Tailnet = raw.CurrentTailnet.Name + } + if raw.Self != nil { + label, _, _ := strings.Cut(strings.TrimSuffix(raw.Self.DNSName, "."), ".") + s.Host = strings.ToLower(label) + for _, ip := range raw.Self.TailscaleIPs { + addr, err := netip.ParseAddr(ip) + switch { + case err != nil: + case addr.Is4() && !s.IPv4.IsValid(): + s.IPv4 = addr + case addr.Is6() && !s.IPv6.IsValid(): + s.IPv6 = addr + } + } + } + return s, nil +} + +// Preserve unknown handlers when updating the Serve config. +type ServeConfig struct { + doc map[string]json.RawMessage + etag string +} + +func (c *Client) ServeConfig(ctx context.Context) (*ServeConfig, error) { + body, etag, err := c.get(ctx, "serve-config") + if err != nil { + return nil, err + } + return ParseServeConfig(body, etag) +} + +func ParseServeConfig(body []byte, etag string) (*ServeConfig, error) { + doc := map[string]json.RawMessage{} + if trimmed := bytes.TrimSpace(body); len(trimmed) > 0 && string(trimmed) != "null" { + if err := json.Unmarshal(trimmed, &doc); err != nil { + return nil, fmt.Errorf("tailnet: reading the Serve config: %w", err) + } + } + return &ServeConfig{doc: doc, etag: etag}, nil +} + +func (cfg *ServeConfig) MarshalJSON() ([]byte, error) { return json.Marshal(cfg.doc) } + +func (c *Client) SetServeConfig(ctx context.Context, cfg *ServeConfig) error { + if c.http == nil { + return ErrUnavailable + } + body, err := cfg.MarshalJSON() + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://local-tailscaled.sock/localapi/v0/serve-config", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Sec-Tailscale", "localapi") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("If-Match", cfg.etag) + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + switch { + case resp.StatusCode == http.StatusPreconditionFailed: + return ErrChanged + case resp.StatusCode == http.StatusForbidden: + return fmt.Errorf("%w: %s", ErrDenied, strings.TrimSpace(string(msg))) + case resp.StatusCode/100 != 2: + return fmt.Errorf("tailnet: writing the Serve config: %s: %s", resp.Status, strings.TrimSpace(string(msg))) + } + return nil +} + +type Holder int + +const ( + Free Holder = iota + Ours + Other +) + +type tcpHandler struct { + HTTPS bool `json:",omitempty"` + TCPForward string `json:",omitempty"` +} + +func (cfg *ServeConfig) tcp() (map[string]json.RawMessage, error) { + ports := map[string]json.RawMessage{} + if raw, ok := cfg.doc["TCP"]; ok && string(raw) != "null" { + if err := json.Unmarshal(raw, &ports); err != nil { + return nil, fmt.Errorf("tailnet: reading the Serve config's TCP ports: %w", err) + } + } + return ports, nil +} + +func (cfg *ServeConfig) Port443(target string) (Holder, error) { + ports, err := cfg.tcp() + if err != nil { + return Other, err + } + raw, ok := ports["443"] + if !ok { + return Free, nil + } + var h tcpHandler + if err := json.Unmarshal(raw, &h); err != nil { + return Other, nil + } + if !h.HTTPS && h.TCPForward == target { + return Ours, nil + } + return Other, nil +} + +// Forward raw TCP so TLS reaches grove with its original server name. +func (cfg *ServeConfig) Claim443(target string) (changed bool, err error) { + holder, err := cfg.Port443(target) + if err != nil || holder == Other { + return false, errors.Join(err, errHeld) + } + if holder == Ours { + return false, nil + } + ports, _ := cfg.tcp() + entry, _ := json.Marshal(tcpHandler{TCPForward: target}) + ports["443"] = entry + return true, cfg.setTCP(ports) +} + +func (cfg *ServeConfig) Release443(target string) (changed bool, err error) { + holder, err := cfg.Port443(target) + if err != nil || holder != Ours { + return false, err + } + ports, _ := cfg.tcp() + delete(ports, "443") + return true, cfg.setTCP(ports) +} + +var errHeld = errors.New("tailnet: port 443 on the tailnet is served by something other than grove") + +func IsHeld(err error) bool { return errors.Is(err, errHeld) } + +func (cfg *ServeConfig) setTCP(ports map[string]json.RawMessage) error { + if len(ports) == 0 { + delete(cfg.doc, "TCP") + return nil + } + raw, err := json.Marshal(ports) + if err != nil { + return err + } + cfg.doc["TCP"] = raw + return nil +} + +func (c *Client) get(ctx context.Context, endpoint string) ([]byte, string, error) { + if c.http == nil { + return nil, "", ErrUnavailable + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://local-tailscaled.sock/localapi/v0/"+endpoint, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("Sec-Tailscale", "localapi") + resp, err := c.http.Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, "", err + } + if resp.StatusCode/100 != 2 { + return nil, "", fmt.Errorf("tailnet: %s: %s: %s", endpoint, resp.Status, strings.TrimSpace(string(body))) + } + return body, resp.Header.Get("Etag"), nil +} diff --git a/internal/tailnet/tailnet_test.go b/internal/tailnet/tailnet_test.go new file mode 100644 index 0000000..e355b7a --- /dev/null +++ b/internal/tailnet/tailnet_test.go @@ -0,0 +1,262 @@ +package tailnet_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/grove-sh/cli/internal/tailnet" +) + +const target = "127.0.0.4:443" + +type fakeTailscaled struct { + mu sync.Mutex + status string + serve string + etag int + denyWrite bool +} + +func (f *fakeTailscaled) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Header.Get("Sec-Tailscale") != "localapi" { + http.Error(w, "missing Sec-Tailscale", http.StatusForbidden) + return + } + switch r.URL.Path { + case "/localapi/v0/status": + io.WriteString(w, f.status) + case "/localapi/v0/serve-config": + if r.Method == http.MethodPost { + if f.denyWrite { + http.Error(w, "serve config denied", http.StatusForbidden) + return + } + if r.Header.Get("If-Match") != f.tag() { + http.Error(w, "etag mismatch", http.StatusPreconditionFailed) + return + } + body, _ := io.ReadAll(r.Body) + f.serve = string(body) + f.etag++ + return + } + w.Header().Set("Etag", f.tag()) + io.WriteString(w, f.serve) + default: + http.NotFound(w, r) + } +} + +func (f *fakeTailscaled) tag() string { return strings.Repeat("e", f.etag+1) } + +func startFake(t *testing.T, f *fakeTailscaled) *tailnet.Client { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "grove") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + socket := filepath.Join(dir, "ts.sock") + ln, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: f} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + return tailnet.New(socket) +} + +func TestStatusReadsTailnetHostAndAddresses(t *testing.T) { + c := startFake(t, &fakeTailscaled{status: `{ + "BackendState": "Running", + "CurrentTailnet": {"Name": "dreamops.dev"}, + "Self": {"DNSName": "Capella.tail0af9ab.ts.net.", "TailscaleIPs": ["100.76.35.70", "fd7a:115c:a1e0::2039:2347"]} + }`}) + s, err := c.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if !s.Running || s.Tailnet != "dreamops.dev" || s.Host != "capella" || + s.IPv4.String() != "100.76.35.70" || s.IPv6.String() != "fd7a:115c:a1e0::2039:2347" { + t.Errorf("got %+v", s) + } +} + +func TestStatusWhileLoggedOut(t *testing.T) { + c := startFake(t, &fakeTailscaled{status: `{"BackendState": "NeedsLogin"}`}) + s, err := c.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if s.Running || s.IPv4.IsValid() { + t.Errorf("got %+v, want not running with no address", s) + } +} + +func TestNoSocketIsUnavailable(t *testing.T) { + _, err := tailnet.New("").Status(context.Background()) + if !errors.Is(err, tailnet.ErrUnavailable) { + t.Errorf("got %v, want ErrUnavailable", err) + } + _, err = tailnet.New("/nonexistent/ts.sock").Status(context.Background()) + if !errors.Is(err, tailnet.ErrUnavailable) { + t.Errorf("missing socket: got %v, want ErrUnavailable", err) + } +} + +func TestClaimRefusesAPortSomeoneElseServes(t *testing.T) { + const t3 = `{"TCP":{"443":{"HTTPS":true}},"Web":{"capella.tail0af9ab.ts.net:443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:3773"}}}}}` + f := &fakeTailscaled{serve: t3} + c := startFake(t, f) + cfg, err := c.ServeConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := cfg.Claim443(target); !tailnet.IsHeld(err) { + t.Fatalf("got %v, want a held port", err) + } + if f.serve != t3 { + t.Errorf("config changed to %s", f.serve) + } +} + +func TestClaimAndReleaseKeepEverythingElse(t *testing.T) { + f := &fakeTailscaled{serve: `{"TCP":{"8443":{"HTTPS":true}},"Web":{"capella.tail0af9ab.ts.net:8443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:3773"}}}},"Future":{"kept":true}}`} + c := startFake(t, f) + ctx := context.Background() + + cfg, err := c.ServeConfig(ctx) + if err != nil { + t.Fatal(err) + } + changed, err := cfg.Claim443(target) + if err != nil || !changed { + t.Fatalf("claim: changed=%v err=%v", changed, err) + } + if err := c.SetServeConfig(ctx, cfg); err != nil { + t.Fatal(err) + } + + var doc struct { + TCP map[string]map[string]any + Web map[string]any + Future map[string]bool + } + if err := json.Unmarshal([]byte(f.serve), &doc); err != nil { + t.Fatal(err) + } + if doc.TCP["443"]["TCPForward"] != target || doc.TCP["8443"]["HTTPS"] != true || + doc.Web["capella.tail0af9ab.ts.net:8443"] == nil || !doc.Future["kept"] { + t.Errorf("after claim: %s", f.serve) + } + + cfg, _ = c.ServeConfig(ctx) + if changed, err := cfg.Claim443(target); err != nil || changed { + t.Errorf("second claim: changed=%v err=%v, want a no-op", changed, err) + } + if changed, err := cfg.Release443(target); err != nil || !changed { + t.Fatalf("release: changed=%v err=%v", changed, err) + } + if err := c.SetServeConfig(ctx, cfg); err != nil { + t.Fatal(err) + } + if strings.Contains(f.serve, `"443"`) || !strings.Contains(f.serve, `"8443"`) { + t.Errorf("after release: %s", f.serve) + } +} + +func TestReleaseLeavesSomeoneElsesPort(t *testing.T) { + c := startFake(t, &fakeTailscaled{serve: `{"TCP":{"443":{"HTTPS":true}}}`}) + cfg, _ := c.ServeConfig(context.Background()) + if changed, err := cfg.Release443(target); err != nil || changed { + t.Errorf("changed=%v err=%v, want untouched", changed, err) + } +} + +func TestWriteAfterSomeoneElsesChangeIsRefused(t *testing.T) { + f := &fakeTailscaled{serve: `{}`} + c := startFake(t, f) + ctx := context.Background() + cfg, _ := c.ServeConfig(ctx) + if _, err := cfg.Claim443(target); err != nil { + t.Fatal(err) + } + f.mu.Lock() + f.serve = `{"TCP":{"443":{"HTTPS":true}}}` + f.etag++ + f.mu.Unlock() + if err := c.SetServeConfig(ctx, cfg); !errors.Is(err, tailnet.ErrChanged) { + t.Errorf("got %v, want ErrChanged", err) + } +} + +func TestEmptyServeConfig(t *testing.T) { + for _, body := range []string{"", "null", "{}"} { + c := startFake(t, &fakeTailscaled{serve: body}) + cfg, err := c.ServeConfig(context.Background()) + if err != nil { + t.Fatalf("%q: %v", body, err) + } + if h, err := cfg.Port443(target); err != nil || h != tailnet.Free { + t.Errorf("%q: holder=%v err=%v, want free", body, h, err) + } + } +} + +func TestServeWriteDeniedIsErrDenied(t *testing.T) { + f := &fakeTailscaled{serve: `{}`, denyWrite: true} + c := startFake(t, f) + ctx := context.Background() + cfg, err := c.ServeConfig(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := cfg.Claim443(target); err != nil { + t.Fatal(err) + } + if err := c.SetServeConfig(ctx, cfg); !errors.Is(err, tailnet.ErrDenied) { + t.Fatalf("got %v, want ErrDenied", err) + } + if f.serve != `{}` { + t.Fatalf("denied write changed config to %s", f.serve) + } +} + +func TestShareRecordRoundTrips(t *testing.T) { + dir := t.TempDir() + if _, ok, err := tailnet.LoadShare(dir); ok || err != nil { + t.Fatalf("fresh dir: ok=%v err=%v", ok, err) + } + want := tailnet.Share{Tailnet: "dreamops.dev", Host: "capella"} + if err := tailnet.SaveShare(dir, want); err != nil { + t.Fatal(err) + } + got, ok, err := tailnet.LoadShare(dir) + if err != nil || !ok || got != want { + t.Fatalf("got %+v ok=%v err=%v", got, ok, err) + } + if zone := tailnet.Zone("grov.site", got, ok); zone != "capella.grov.site" { + t.Errorf("zone %q", zone) + } + if err := tailnet.RemoveShare(dir); err != nil { + t.Fatal(err) + } + if err := tailnet.RemoveShare(dir); err != nil { + t.Errorf("removing twice: %v", err) + } + if zone := tailnet.Zone("grov.site", tailnet.Share{}, false); zone != "grov.site" { + t.Errorf("unshared zone %q", zone) + } +} From cbc730344ef9ef6f2f68cfddafa3dd02c0934001 Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Sun, 1 Nov 2026 13:00:18 -0500 Subject: [PATCH 2/3] Trust other machines' roots, and send this one's to a phone - grove trust fetches a sharing machine's root over its tailnet address and installs it - grove trust --send delivers the root to a phone over Taildrop - The split DNS resolver answers over TCP too, which resolvers fall back to --- CLAUDE.md | 4 +- cmd/grove/install.go | 9 + cmd/grove/main.go | 1 + cmd/grove/main_test.go | 14 + cmd/grove/trust.go | 378 +++++++++++++++++++++++ cmd/grove/trust_test.go | 152 +++++++++ docs/content/guides/tailnet.mdx | 14 +- docs/content/reference/cli/index.mdx | 1 + docs/content/reference/cli/meta.ts | 1 + docs/content/reference/cli/trust.mdx | 37 +++ docs/content/reference/cli/uninstall.mdx | 2 +- internal/daemon/server.go | 1 + internal/daemon/sharing.go | 34 +- internal/daemon/sharing_test.go | 26 ++ internal/proxy/proxy.go | 28 +- internal/proxy/proxy_test.go | 27 ++ internal/tailnet/dns.go | 45 +++ internal/tailnet/dns_test.go | 22 ++ internal/tailnet/tailnet.go | 108 ++++++- internal/tailnet/tailnet_test.go | 80 +++++ 20 files changed, 960 insertions(+), 24 deletions(-) create mode 100644 cmd/grove/trust.go create mode 100644 cmd/grove/trust_test.go create mode 100644 docs/content/reference/cli/trust.mdx diff --git a/CLAUDE.md b/CLAUDE.md index 8e96dd4..9f571b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Releases are cut by pushing a `v*` tag. No version is written down in the repo: ## Layout -`cmd/grove` is the cobra CLI, one file per command. Beyond `exec`, `ls`, `hold`, `release`, `forget`, and the `start`/`stop`/`restart` trio, that is `install`/`uninstall` (the privileged and trust-store path), `share` (reaching contexts from other devices on a tailnet), `doctor` (a finding per check, with a remedy where there is one to name, and usually where a platform change needs its matching edit), `init`, `env`, and `context`. `internal/*` holds everything with logic in it: +`cmd/grove` is the cobra CLI, one file per command. Beyond `exec`, `ls`, `hold`, `release`, `forget`, and the `start`/`stop`/`restart` trio, that is `install`/`uninstall` (the privileged and trust-store path), `share` (reaching contexts from other devices on a tailnet), `trust` (other machines' roots here, and this one's to a phone over Taildrop), `doctor` (a finding per check, with a remedy where there is one to name, and usually where a platform change needs its matching edit), `init`, `env`, and `context`. `internal/*` holds everything with logic in it: | | | |-|-| @@ -85,6 +85,8 @@ The four places still naming `127.0.0.1` are the other side of the proxy, where **Sharing moves hostnames one label down.** `grove share` writes `share.json` beside the CA, naming a tailnet and the machine's MagicDNS label, and from then on `tailnet.Zone` makes every hostname `..grov.site`, in the CLI (`zone()` in `cmd/grove/share.go`) and the daemon (`Config.Zone`) alike. The record is held rather than re-read from Tailscale on purpose: hostnames must not move when tailscaled stops or switches tailnets. The daemon's `sharing` loop asks tailscaled's local API every ten seconds, and only on the recorded tailnet does it bind the split DNS resolver on the tailnet address and claim tailnet 443 through Serve as a raw TCP forward to its own listener. It never takes 443 from another Serve handler (t3code's is the usual one), and it releases its own mapping on any other tailnet. It also re-reads `share.json` on every check and stands down when the record is gone or changed, since otherwise `share --off` would be undone within ten seconds by a daemon still holding the share it started with. Linux only: `platform.TailscaleSocket` is empty elsewhere. +**Trust between machines rides on the tailnet's own authentication.** The proxy answers `proxy.RootPath` on the zone's bare hostname with the root, and counts that host as routed so it gets a leaf. `grove trust ` fetches it with certificate verification off, which is the one place grove does that: nothing trusts the peer's root yet, so the stand-in is dialling the peer's tailnet IP from tailscaled's peer list, never DNS, which WireGuard ties to that node's key. It then insists the served leaf chains to the fetched root. Peer roots are kept under `peers/` in the state directory so `--remove` and `uninstall` undo exactly what trust did. `cmd/grove`'s `TestMain` points `GROVE_STATE_DIR` at a temp directory, because a maintainer's own `share.json` would otherwise rename every hostname the suite expects. + `grov.site` is real wildcard DNS pointing at `platform.Address`, so there are no hosts-file entries to manage. `*.v4.grov.site` also points there and is kept for the 0.3.0 betas, which shipped while `*.grov.site` still answered `127.0.0.1`: deleting it would break a published version. The domain is not a config key yet: `defaultDomain` in `cmd/grove/main.go` is a constant, and nothing in `internal/config` reads a domain. ## Conventions in this codebase diff --git a/cmd/grove/install.go b/cmd/grove/install.go index edf9d34..ef70fe1 100644 --- a/cmd/grove/install.go +++ b/cmd/grove/install.go @@ -195,6 +195,15 @@ anything is answering on a port grove leased.`, return err } } + // Roots grove trust added for other machines go with this one: + // they are grove's doing too, and nothing else would remove them. + if removeTrust { + for _, peer := range trustedPeers(stateDir) { + if err := untrustPeer(out, stateDir, peer.host); err != nil { + return err + } + } + } // After the trust store, which is what someone came for. The // redirect outlives this process either way. diff --git a/cmd/grove/main.go b/cmd/grove/main.go index bfe7aac..0498864 100644 --- a/cmd/grove/main.go +++ b/cmd/grove/main.go @@ -107,6 +107,7 @@ already reads.`, newUninstallCommand(), newLsCommand(), newShareCommand(), + newTrustCommand(), ) return root } diff --git a/cmd/grove/main_test.go b/cmd/grove/main_test.go index e1ff707..0fd3561 100644 --- a/cmd/grove/main_test.go +++ b/cmd/grove/main_test.go @@ -24,6 +24,20 @@ func TestMain(m *testing.M) { if os.Getenv("GROVE_TEST_RUN_CLI") == "1" { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + // The same reasoning for the state directory: a maintainer whose machine + // shares on a tailnet has a share.json there, and every hostname the + // suite expects would gain their machine's name. Tests that want a state + // directory of their own still set one. + if os.Getenv("GROVE_STATE_DIR") == "" { + dir, err := os.MkdirTemp("", "grove-state") + if err != nil { + panic(err) + } + os.Setenv("GROVE_STATE_DIR", dir) + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) + } os.Exit(m.Run()) } diff --git a/cmd/grove/trust.go b/cmd/grove/trust.go new file mode 100644 index 0000000..bde5d85 --- /dev/null +++ b/cmd/grove/trust.go @@ -0,0 +1,378 @@ +package main + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/grove-sh/cli/internal/ca" + "github.com/grove-sh/cli/internal/daemon" + "github.com/grove-sh/cli/internal/platform" + "github.com/grove-sh/cli/internal/proxy" + "github.com/grove-sh/cli/internal/tailnet" + "github.com/grove-sh/cli/internal/trust" +) + +// Peer roots live beside this machine's own, one file per host, so remove and +// uninstall take back exactly what trust added and nothing the machine's owner +// trusted some other way. +const peersDir = "peers" + +// A tailnet device name is one DNS label. Checked before it becomes part of a +// path under the state directory. +var hostLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +func newTrustCommand() *cobra.Command { + var stateDir, send string + var remove bool + + cmd := &cobra.Command{ + Use: "trust [host]", + Short: "Trust another machine's grove root, or send this machine's to a device", + Long: `Trust another machine's grove root, or send this machine's to a device. + +Each machine that runs grove has its own root, so opening another machine's +hostnames means trusting its root too. grove trust fetches the root of a +machine sharing on your tailnet and installs it into this machine's trust +stores, which may ask for your password. It connects to that machine's tailnet +address, taken from Tailscale rather than DNS, and Tailscale only lets that +machine answer there. Grove also checks that the certificate it was served +chains to the root it received, and prints the fingerprint. + +grove trust --send sends this machine's root to a phone or another +device over Taildrop, and says how to install it there. Run it on each machine +whose hostnames the device should open. + +With no arguments, it prints this machine's root fingerprint and the roots it +trusts from other machines. Linux only for now, apart from that listing.`, + Example: ` grove trust # this machine's fingerprint, and the peers it trusts + grove trust capella # trust capella's root here + grove trust --send pixel-9 # send this machine's root to a phone + grove trust --remove capella # stop trusting capella's root`, + Args: usageArgs(cobra.MaximumNArgs(1)), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + client := tailnet.New(platform.TailscaleSocket()) + switch { + case send != "" && (len(args) > 0 || remove): + return usageError{errors.New("--send takes the device on its own, with no host and no --remove")} + case send != "": + return sendRoot(out, client, stateDir, send) + case remove && len(args) == 0: + return usageError{errors.New("--remove needs the host to stop trusting")} + case remove: + return untrustPeer(out, stateDir, args[0]) + case len(args) == 1: + return trustPeer(out, client, stateDir, args[0]) + } + return listTrust(out, stateDir) + }, + } + cmd.Flags().StringVar(&stateDir, "state-dir", daemon.StateDir(), "directory holding the CA and trusted peer roots") + cmd.Flags().StringVar(&send, "send", "", "send this machine's root to a device over Taildrop") + cmd.Flags().BoolVar(&remove, "remove", false, "stop trusting the named host's root") + return cmd +} + +func fingerprint(cert *x509.Certificate) string { + sum := sha256.Sum256(cert.Raw) + return hex.EncodeToString(sum[:]) +} + +func peerFile(stateDir, host string) string { + return filepath.Join(stateDir, peersDir, host+".crt") +} + +func checkHost(host string) (string, error) { + host = strings.ToLower(host) + if !hostLabel.MatchString(host) { + return "", fmt.Errorf("%q is not a tailnet device name; tailscale status lists them", host) + } + return host, nil +} + +func listTrust(out io.Writer, stateDir string) error { + root, err := ca.Open(stateDir) + if err != nil { + if errors.Is(err, ca.ErrNoAuthority) { + return fmt.Errorf("%w; run %s install first", err, invocation()) + } + return err + } + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "this machine\t%s\n", fingerprint(root.Certificate())) + for _, peer := range trustedPeers(stateDir) { + state := fingerprint(peer.cert) + if !trust.Trusted(peer.cert) { + state += " (no longer in the trust store; run " + invocation() + " trust " + peer.host + " again)" + } + fmt.Fprintf(w, "%s\t%s\n", peer.host, state) + } + return w.Flush() +} + +type trustedPeer struct { + host string + cert *x509.Certificate +} + +func trustedPeers(stateDir string) []trustedPeer { + entries, _ := os.ReadDir(filepath.Join(stateDir, peersDir)) + var peers []trustedPeer + for _, e := range entries { + host, ok := strings.CutSuffix(e.Name(), ".crt") + if !ok { + continue + } + cert, err := readCert(filepath.Join(stateDir, peersDir, e.Name())) + if err != nil { + continue + } + peers = append(peers, trustedPeer{host: host, cert: cert}) + } + sort.Slice(peers, func(i, j int) bool { return peers[i].host < peers[j].host }) + return peers +} + +func readCert(path string) (*x509.Certificate, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return parseRoot(raw) +} + +// parseRoot accepts one CA certificate and nothing else, so a peer cannot hand +// over a leaf or a bundle and have it installed as a root. +func parseRoot(raw []byte) (*x509.Certificate, error) { + block, rest := pem.Decode(raw) + if block == nil || block.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) > 0 { + return nil, errors.New("not a single PEM certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + if !cert.IsCA || !cert.BasicConstraintsValid { + return nil, errors.New("not a certificate authority") + } + return cert, nil +} + +func trustPeer(out io.Writer, client *tailnet.Client, stateDir, host string) error { + paint := styles(out) + host, err := checkHost(host) + if err != nil { + return err + } + if platform.TailscaleSocket() == "" { + return errors.New("trusting another machine's root is Linux-only for now") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + status, err := client.Status(ctx) + switch { + case errors.Is(err, tailnet.ErrUnavailable): + return errors.New("tailscaled is not answering, so grove cannot reach " + host + "; check tailscale status") + case err != nil: + return err + case !status.Running: + return errors.New("tailscale is not connected; run tailscale up first") + } + peer, ok := status.Peer(host) + if !ok || !peer.IPv4.IsValid() { + return fmt.Errorf("no device called %s on %s; tailscale status lists them", host, status.Tailnet) + } + + cert, raw, err := fetchRoot(ctx, host+"."+defaultDomain, net.JoinHostPort(peer.IPv4.String(), "443")) + if err != nil { + return fmt.Errorf("fetching %s's root: %w\nis %s sharing? run %s share there", host, err, host, invocation()) + } + if own, err := ca.Open(stateDir); err == nil && fingerprint(own.Certificate()) == fingerprint(cert) { + return fmt.Errorf("%s served this machine's own root; nothing to add", host) + } + + fmt.Fprintf(out, "%s's root %s\n", host, fingerprint(cert)) + if trust.Trusted(cert) { + fmt.Fprintln(out, paint.good("already trusted")) + } else { + fmt.Fprintln(out, paint.dim("installing it into your trust stores, sudo may ask for your password")) + if err := trust.Install(cert); err != nil { + return err + } + fmt.Fprintln(out, paint.good("installed")) + } + if err := os.MkdirAll(filepath.Join(stateDir, peersDir), 0o755); err != nil { + return err + } + if err := os.WriteFile(peerFile(stateDir, host), raw, 0o644); err != nil { + return err + } + fmt.Fprintf(out, "\nhttps://.%s.%s now opens here without a warning.\n", host, defaultDomain) + return nil +} + +// fetchRoot asks the daemon at addr for its root and refuses one that the +// connection's own certificate does not chain to. +// +// Verification is skipped for this one request because nothing here trusts the +// peer's root yet; it is the thing being fetched. What stands in for it is the +// address: addr is the peer's tailnet IP from Tailscale, never a DNS answer, and +// WireGuard only lets that peer's node key answer there. +func fetchRoot(ctx context.Context, host, addr string) (*x509.Certificate, []byte, error) { + var dialer net.Dialer + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp", addr) + }, + TLSClientConfig: &tls.Config{ServerName: host, InsecureSkipVerify: true}, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+proxy.RootPath, nil) + if err != nil { + return nil, nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("%s answered %s, so it may run a grove without grove trust", host, resp.Status) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return nil, nil, err + } + cert, err := parseRoot(raw) + if err != nil { + return nil, nil, fmt.Errorf("what %s served is %v", host, err) + } + if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { + return nil, nil, errors.New("the connection carried no certificate") + } + pool := x509.NewCertPool() + pool.AddCert(cert) + intermediates := x509.NewCertPool() + for _, c := range resp.TLS.PeerCertificates[1:] { + intermediates.AddCert(c) + } + if _, err := resp.TLS.PeerCertificates[0].Verify(x509.VerifyOptions{ + DNSName: host, Roots: pool, Intermediates: intermediates, + }); err != nil { + return nil, nil, fmt.Errorf("the certificate %s served does not chain to the root it offered: %w", host, err) + } + return cert, raw, nil +} + +func untrustPeer(out io.Writer, stateDir, host string) error { + host, err := checkHost(host) + if err != nil { + return err + } + cert, err := readCert(peerFile(stateDir, host)) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("grove trusts no root from %s; %s trust lists the ones it does", host, invocation()) + } else if err != nil { + return err + } + fmt.Fprintln(out, styles(out).dim("removing "+host+"'s root from your trust stores, sudo may ask for your password")) + if err := trust.Uninstall(cert); err != nil { + return err + } + if err := os.Remove(peerFile(stateDir, host)); err != nil { + return err + } + fmt.Fprintf(out, "stopped trusting %s's root %s\n", host, fingerprint(cert)) + return nil +} + +func sendRoot(out io.Writer, client *tailnet.Client, stateDir, device string) error { + device, err := checkHost(device) + if err != nil { + return err + } + if platform.TailscaleSocket() == "" { + return errors.New("sending the root over Taildrop is Linux-only for now") + } + root, err := ca.Open(stateDir) + if err != nil { + if errors.Is(err, ca.ErrNoAuthority) { + return fmt.Errorf("%w; run %s install first", err, invocation()) + } + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + status, err := client.Status(ctx) + switch { + case errors.Is(err, tailnet.ErrUnavailable): + return errors.New("tailscaled is not answering; check tailscale status") + case err != nil: + return err + case !status.Running: + return errors.New("tailscale is not connected; run tailscale up first") + } + peer, ok := status.Peer(device) + if !ok { + return fmt.Errorf("no device called %s on %s; tailscale status lists them", device, status.Tailnet) + } + if !peer.Online { + return fmt.Errorf("%s is offline; open Tailscale on it, then try again", device) + } + + // Named for the machine it vouches for, since a phone collects one per + // machine and the names are all the install screen shows. + self := status.Host + if record, shared, err := tailnet.LoadShare(stateDir); err == nil && shared { + self = record.Host + } + name := "grove-" + self + "-root.crt" + fmt.Fprintln(out, styles(out).dim("sending "+name+" to "+device+"; if this waits, open Tailscale on "+device)) + sendCtx, cancelSend := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancelSend() + err = client.SendFile(sendCtx, peer, name, root.RootPEM()) + switch { + case errors.Is(err, tailnet.ErrNotAccepted): + return fmt.Errorf("%s did not take the file within two minutes; open Tailscale on it so it can receive, then try again", device) + case errors.Is(err, tailnet.ErrDenied): + return errors.New("this user may not send files over Tailscale; run " + tailnet.OperatorFix + ", then try again") + case err != nil: + return err + } + + fmt.Fprintf(out, "sent %s to %s\nfingerprint %s\n\n", name, device, fingerprint(root.Certificate())) + switch strings.ToLower(peer.OS) { + case "android": + fmt.Fprintf(out, "On the phone, Tailscale saves it to Downloads. Install it from Settings,\n"+ + "Security, Encryption & credentials, Install a certificate, CA certificate,\n"+ + "and pick %s.\n", name) + case "ios": + fmt.Fprintf(out, "On the phone, Taildrop saves it in Files, under Tailscale. Tap %s,\n"+ + "install the profile in Settings, General, VPN & Device Management, then turn\n"+ + "on full trust in Settings, General, About, Certificate Trust Settings.\n", name) + default: + fmt.Fprintf(out, "On %s, add %s to its trust store, or run %s trust %s there\n"+ + "if it runs grove.\n", device, name, invocation(), self) + } + return nil +} diff --git a/cmd/grove/trust_test.go b/cmd/grove/trust_test.go new file mode 100644 index 0000000..4d6548e --- /dev/null +++ b/cmd/grove/trust_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/pem" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/grove-sh/cli/internal/ca" + "github.com/grove-sh/cli/internal/proxy" +) + +// servePeer stands up what a sharing machine's daemon runs: a proxy answering +// the root on its zone's bare hostname, with leaves from signer. offered is the +// root it hands out, which an impostor makes differ from signer. +func servePeer(t *testing.T, signer, offered *ca.CA, host string) string { + t.Helper() + p := proxy.New() + p.ServeRoot(host, offered.RootPEM()) + iss := ca.NewIssuer(signer, "grov.site", p.Routes) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go p.Serve(ln, iss.GetCertificate) + t.Cleanup(func() { p.Shutdown(context.Background()) }) + return ln.Addr().String() +} + +func newCA(t *testing.T) *ca.CA { + t.Helper() + root, err := ca.OpenOrCreate(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return root +} + +func TestFetchRootFromASharingPeer(t *testing.T) { + root := newCA(t) + addr := servePeer(t, root, root, "capella.grov.site") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cert, raw, err := fetchRoot(ctx, "capella.grov.site", addr) + if err != nil { + t.Fatal(err) + } + if fingerprint(cert) != fingerprint(root.Certificate()) || !bytes.Equal(raw, root.RootPEM()) { + t.Error("fetched a different root than the peer holds") + } +} + +// The check that makes the fetch worth trusting beyond the tailnet address: a +// root the connection's certificate does not chain to is refused. +func TestFetchRootRefusesARootTheConnectionDoesNotChainTo(t *testing.T) { + signer, offered := newCA(t), newCA(t) + addr := servePeer(t, signer, offered, "capella.grov.site") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, _, err := fetchRoot(ctx, "capella.grov.site", addr); err == nil || !strings.Contains(err.Error(), "does not chain") { + t.Errorf("got %v, want a refusal", err) + } +} + +// A grove from before trust answers the path with its 503 page. +func TestFetchRootFromAGroveWithoutTrust(t *testing.T) { + root := newCA(t) + addr := servePeer(t, root, root, "elsewhere.grov.site") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, _, err := fetchRoot(ctx, "capella.grov.site", addr); err == nil || !strings.Contains(err.Error(), "503") { + t.Errorf("got %v, want the status named", err) + } +} + +func TestParseRootTakesOneAuthorityOnly(t *testing.T) { + root := newCA(t) + if _, err := parseRoot(root.RootPEM()); err != nil { + t.Fatalf("root: %v", err) + } + leaf, err := root.Leaf("app.capella.grov.site") + if err != nil { + t.Fatal(err) + } + leafPEM := pemOf(leaf) + if _, err := parseRoot(leafPEM); err == nil { + t.Error("accepted a leaf as a root") + } + if _, err := parseRoot(append(root.RootPEM(), root.RootPEM()...)); err == nil { + t.Error("accepted a bundle") + } + if _, err := parseRoot([]byte("503")); err == nil { + t.Error("accepted something that is not PEM") + } +} + +func pemOf(cert *tls.Certificate) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}) +} + +// The host becomes a file name under the state directory. +func TestTrustRefusesNamesThatAreNotDeviceNames(t *testing.T) { + for _, host := range []string{"../root", "a/b", "", "-capella", "capella.example"} { + if _, err := checkHost(host); err == nil { + t.Errorf("%q accepted", host) + } + } + for _, host := range []string{"capella", "pixel-9", "Regulus"} { + if _, err := checkHost(host); err != nil { + t.Errorf("%q refused: %v", host, err) + } + } +} + +func TestRemoveWithNothingTrusted(t *testing.T) { + var out bytes.Buffer + err := untrustPeer(&out, t.TempDir(), "capella") + if err == nil || !strings.Contains(err.Error(), "trusts no root from capella") { + t.Errorf("got %v", err) + } +} + +func TestListShowsThisMachineAndItsPeers(t *testing.T) { + dir := t.TempDir() + root, err := ca.OpenOrCreate(dir) + if err != nil { + t.Fatal(err) + } + peer := newCA(t) + if err := os.MkdirAll(dir+"/peers", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(peerFile(dir, "capella"), peer.RootPEM(), 0o644); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := listTrust(&out, dir); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), fingerprint(root.Certificate())) || + !strings.Contains(out.String(), "capella") || !strings.Contains(out.String(), fingerprint(peer.Certificate())) { + t.Errorf("listing:\n%s", out.String()) + } +} diff --git a/docs/content/guides/tailnet.mdx b/docs/content/guides/tailnet.mdx index 3e5e66f..a48774f 100644 --- a/docs/content/guides/tailnet.mdx +++ b/docs/content/guides/tailnet.mdx @@ -41,7 +41,19 @@ The restart matters. Every hostname on the machine changes, so the daemon and an Every device needs two things. The first is **Use Tailscale DNS** turned on in the Tailscale app, or `--accept-dns` on Linux, which is the default. Without it the split DNS entry never applies. If the device uses an exit node, also turn on Use with exit node for each entry. -The second is **the serving machine's root, trusted**. Each machine has its own, at `root.crt` in its state directory, `~/.local/state/grove` on Linux. Copy it over and install it as a CA certificate: into the system trust store on a computer, or on Android through Settings, Security, Encryption & credentials, Install a certificate, CA certificate. Only the `.crt` travels, never the key. +The second is **the serving machine's root, trusted**. Each machine has its own. On another machine that runs grove, trust it by name: + +```sh +grove trust capella +``` + +For a phone, send each serving machine's root to it over Taildrop, from that machine, and follow the install steps it prints: + +```sh +grove trust --send pixel-9 +``` + +Only the root's certificate travels, never its key. [`grove trust`](/reference/cli/trust) has the details, including how the fetch knows it reached the right machine. ## Switching tailnets diff --git a/docs/content/reference/cli/index.mdx b/docs/content/reference/cli/index.mdx index 057c438..71c7e09 100644 --- a/docs/content/reference/cli/index.mdx +++ b/docs/content/reference/cli/index.mdx @@ -10,6 +10,7 @@ grove init write a grove.toml for this project grove install set this machine up: authority, trust, port 443 grove uninstall stop grove and untrust its root grove share [--off] reach this machine's contexts from your other devices +grove trust [host] [--send ] trust another machine's root, or send this one's grove exec [-s ] -- run a command in this context grove env [--format shell|json] print that environment instead of running grove ls [--all] this context's routes, or every lease on the machine diff --git a/docs/content/reference/cli/meta.ts b/docs/content/reference/cli/meta.ts index 9a7dc9e..cd2670f 100644 --- a/docs/content/reference/cli/meta.ts +++ b/docs/content/reference/cli/meta.ts @@ -7,6 +7,7 @@ export default defineMeta({ "install", "uninstall", "share", + "trust", "exec", "env", "ls", diff --git a/docs/content/reference/cli/trust.mdx b/docs/content/reference/cli/trust.mdx new file mode 100644 index 0000000..a383330 --- /dev/null +++ b/docs/content/reference/cli/trust.mdx @@ -0,0 +1,37 @@ +--- +title: grove trust +description: Trust another machine's grove root, or send this machine's to a phone or other device over Taildrop. +--- + +```sh +grove trust [host] [--remove] [--send ] [--state-dir ] +``` + +Trust another machine's grove root, or send this machine's to a device. + +Each machine that runs grove has its own root, so opening another machine's hostnames means trusting its root too. `grove trust ` fetches the root of a machine sharing on your tailnet and installs it into this machine's trust stores, which may ask for your password. It connects to that machine's tailnet address, taken from Tailscale rather than DNS, and Tailscale only lets that machine answer there. Grove also checks that the certificate it was served chains to the root it received, and prints the fingerprint. + +`grove trust --send ` sends this machine's root to a phone or another device over Taildrop, and says how to install it there. Run it on each machine whose hostnames the device should open. + +With no arguments, it prints this machine's root fingerprint and the roots it trusts from other machines. Linux only for now, apart from that listing. + +The roots it adds are kept under `peers/` in the state directory, so `--remove` and [`grove uninstall`](/reference/cli/uninstall) take back exactly those and nothing trusted some other way. + +## Flags + +| Flag | Meaning | +|------|---------| +| `--send` | Send this machine's root to the named tailnet device over Taildrop. Takes the device on its own. | +| `--remove` | Stop trusting the named host's root, and remove it from the trust stores. | +| `--state-dir` | The directory holding the CA and the trusted peer roots. | + +## Examples + +```sh +grove trust # this machine's fingerprint, and the peers it trusts +grove trust capella # trust capella's root here +grove trust --send pixel-9 # send this machine's root to a phone +grove trust --remove capella # stop trusting capella's root +``` + +The machine being trusted has to be sharing, since its daemon hands out the root at `https://.grov.site/.well-known/grove/root.crt` over the tailnet. Sending needs only Tailscale. Both may need [operator access](/reference/cli/share) for this user. diff --git a/docs/content/reference/cli/uninstall.mdx b/docs/content/reference/cli/uninstall.mdx index ec65d91..b3f8316 100644 --- a/docs/content/reference/cli/uninstall.mdx +++ b/docs/content/reference/cli/uninstall.mdx @@ -9,7 +9,7 @@ grove uninstall [--yes] [--force] [--trust=false] Remove grove's root from this machine's trust stores. -The CA files stay on disk, so a later `grove install` trusts the same root rather than generating another one. Certificates already issued keep working for anything that still trusts the root. +Roots that [`grove trust`](/reference/cli/trust) added for other machines are removed from the trust stores too. The CA files stay on disk, so a later `grove install` trusts the same root rather than generating another one. Certificates already issued keep working for anything that still trusts the root. Where a platform needed a privileged step to reach port 443, this prints the step that undoes it and offers to run it, the same way install did the one that set it up. `--yes` answers in advance. On macOS, removing a trust root asks for authorization, since that isn't something to do quietly. diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 2f22d1d..45a97ea 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -85,6 +85,7 @@ func New(cfg Config) (*Server, error) { return nil, err } prx := proxy.New() + prx.ServeRoot(zoneOr(cfg.Zone, cfg.Domain), root.RootPEM()) issuer := ca.NewIssuer(root, cfg.Domain, prx.Routes) // Beside the authority, so a daemon pointed elsewhere keeps its allocations // there too. diff --git a/internal/daemon/sharing.go b/internal/daemon/sharing.go index a1664a0..512bc35 100644 --- a/internal/daemon/sharing.go +++ b/internal/daemon/sharing.go @@ -34,6 +34,7 @@ type sharing struct { mu sync.Mutex resolver net.PacketConn + tcp net.Listener bound netip.Addr // Suppress repeated log entries while the sharing state is unchanged. said string @@ -102,27 +103,40 @@ func (s *sharing) bindResolver(status tailnet.Status) error { if s.resolver != nil && s.bound == status.IPv4 { return nil } - if s.resolver != nil { - s.resolver.Close() - s.resolver = nil + s.closeResolverLocked() + addr := net.JoinHostPort(status.IPv4.String(), strconv.Itoa(s.dnsPort)) + conn, err := net.ListenPacket("udp", addr) + if err != nil { + return err } - conn, err := net.ListenPacket("udp", net.JoinHostPort(status.IPv4.String(), strconv.Itoa(s.dnsPort))) + ln, err := net.Listen("tcp", addr) if err != nil { + conn.Close() return err } - s.resolver, s.bound = conn, status.IPv4 - go tailnet.Resolver{Zone: s.zone, IPv4: status.IPv4, IPv6: status.IPv6}.Serve(conn) + s.resolver, s.tcp, s.bound = conn, ln, status.IPv4 + r := tailnet.Resolver{Zone: s.zone, IPv4: status.IPv4, IPv6: status.IPv6} + go r.Serve(conn) + go r.ServeTCP(ln) return nil } -func (s *sharing) stopResolver() { - s.mu.Lock() - defer s.mu.Unlock() +func (s *sharing) closeResolverLocked() { if s.resolver != nil { s.resolver.Close() s.resolver = nil - s.bound = netip.Addr{} } + if s.tcp != nil { + s.tcp.Close() + s.tcp = nil + } + s.bound = netip.Addr{} +} + +func (s *sharing) stopResolver() { + s.mu.Lock() + defer s.mu.Unlock() + s.closeResolverLocked() } func (s *sharing) claim(ctx context.Context) error { diff --git a/internal/daemon/sharing_test.go b/internal/daemon/sharing_test.go index 7b2ad6b..2ffb799 100644 --- a/internal/daemon/sharing_test.go +++ b/internal/daemon/sharing_test.go @@ -220,3 +220,29 @@ func TestSharingStopsWhenTheRecordGoes(t *testing.T) { t.Errorf("last log %q does not say to restart", last) } } + +// Resolvers fall back to TCP, so the daemon binds both and takes both down. +func TestSharingAnswersOverTCPToo(t *testing.T) { + f := &fakeTailscale{status: onTailnet("dreamops.dev"), serve: `{}`} + s, _ := newSharing(t, f) + s.tick(context.Background()) + + tcpLookup := func() error { + r := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(s.dnsPort))) + }} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := r.LookupNetIP(ctx, "ip4", "app.capella.grov.site") + return err + } + if err := tcpLookup(); err != nil { + t.Fatalf("over TCP: %v", err) + } + f.set(func(f *fakeTailscale) { f.status = onTailnet("client.example") }) + s.tick(context.Background()) + if err := tcpLookup(); err == nil { + t.Error("still answering over TCP on another tailnet") + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a510351..c178add 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -20,11 +20,21 @@ type Route struct { Upstream string } +// RootPath is where the zone's bare hostname hands out the root, so a peer can +// fetch it with grove trust. The certificate is public; only its key is not. +const RootPath = "/.well-known/grove/root.crt" + type Server struct { routes atomic.Pointer[map[string]*httputil.ReverseProxy] + root atomic.Pointer[rootDoc] http *http.Server } +type rootDoc struct { + host string + pem []byte +} + func New() *Server { s := &Server{} s.SetRoutes(nil) @@ -44,8 +54,19 @@ func (s *Server) SetRoutes(routes []Route) { s.routes.Store(&table) } +// ServeRoot answers RootPath on host with pem. +func (s *Server) ServeRoot(host string, pem []byte) { + s.root.Store(&rootDoc{host: strings.ToLower(host), pem: pem}) +} + +// Routes counts the root's host too, so it gets a leaf naming it: the peer +// fetching the root checks the connection's certificate chains to what it got. func (s *Server) Routes(host string) bool { - _, ok := (*s.routes.Load())[strings.ToLower(host)] + host = strings.ToLower(host) + if doc := s.root.Load(); doc != nil && doc.host == host { + return true + } + _, ok := (*s.routes.Load())[host] return ok } @@ -63,6 +84,11 @@ func (s *Server) Shutdown(ctx context.Context) error { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { host := hostOnly(r.Host) + if doc := s.root.Load(); doc != nil && host == doc.host && r.URL.Path == RootPath { + w.Header().Set("Content-Type", "application/x-pem-file") + w.Write(doc.pem) + return + } rp, ok := (*s.routes.Load())[host] if !ok { unavailable(w, host, "No grove context is bound to this hostname.") diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 347d228..195c92c 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -322,3 +322,30 @@ func TestReachesAnUpstreamOnIPv6Loopback(t *testing.T) { t.Errorf("body = %q", got) } } + +// The root answers on the zone's bare hostname at one path, and nowhere else, +// so it cannot shadow a context's own routes. +func TestServesTheRootOnlyAtItsHostAndPath(t *testing.T) { + p := proxy.New() + p.ServeRoot("capella.grov.site", []byte("PEM")) + + get := func(host, path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "https://"+host+path, nil) + p.ServeHTTP(rec, req) + return rec + } + if rec := get("Capella.grov.site", proxy.RootPath); rec.Code != 200 || rec.Body.String() != "PEM" || + rec.Header().Get("Content-Type") != "application/x-pem-file" { + t.Errorf("root: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type")) + } + if rec := get("capella.grov.site", "/"); rec.Code != 503 { + t.Errorf("other path on the root host: %d", rec.Code) + } + if rec := get("app.capella.grov.site", proxy.RootPath); rec.Code != 503 { + t.Errorf("root path on another host: %d", rec.Code) + } + if !p.Routes("capella.grov.site") || p.Routes("app.capella.grov.site") { + t.Error("Routes should name the root's host so it gets a leaf, and nothing else unrouted") + } +} diff --git a/internal/tailnet/dns.go b/internal/tailnet/dns.go index cdf7efa..3a9e042 100644 --- a/internal/tailnet/dns.go +++ b/internal/tailnet/dns.go @@ -3,9 +3,11 @@ package tailnet import ( "encoding/binary" "errors" + "io" "net" "net/netip" "strings" + "time" ) const ( @@ -43,6 +45,49 @@ func (r Resolver) Serve(conn net.PacketConn) error { } } +// ServeTCP answers the same queries over TCP, each message prefixed with its +// length. Resolvers fall back to TCP and some check a server over it, so one +// that refuses TCP can be reported unreachable while UDP works fine. +func (r Resolver) ServeTCP(ln net.Listener) error { + for { + conn, err := ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } + return err + } + go r.serveConn(conn) + } +} + +// A client may send several queries on one connection; an idle one is closed +// so a peer cannot hold sockets open indefinitely. +func (r Resolver) serveConn(conn net.Conn) { + defer conn.Close() + var size [2]byte + for { + conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := io.ReadFull(conn, size[:]); err != nil { + return + } + query := make([]byte, binary.BigEndian.Uint16(size[:])) + if _, err := io.ReadFull(conn, query); err != nil { + return + } + reply := r.Answer(query) + if reply == nil { + return + } + if _, err := conn.Write(binary.BigEndian.AppendUint16(nil, uint16(len(reply)))); err != nil { + return + } + if _, err := conn.Write(reply); err != nil { + return + } + } +} + func (r Resolver) Answer(query []byte) []byte { if len(query) < 12 { return nil diff --git a/internal/tailnet/dns_test.go b/internal/tailnet/dns_test.go index bffbeee..ccc36c5 100644 --- a/internal/tailnet/dns_test.go +++ b/internal/tailnet/dns_test.go @@ -93,3 +93,25 @@ func TestResolverIgnoresGarbage(t *testing.T) { t.Errorf("truncated name: got %v, want FORMERR", reply) } } + +func TestResolverAnswersOverTCP(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go tailnet.Resolver{Zone: "capella.grov.site", IPv4: netip.MustParseAddr("100.76.35.70")}.ServeTCP(ln) + r := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", ln.Addr().String()) + }} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + addrs, err := r.LookupNetIP(ctx, "ip4", "app.capella.grov.site") + if err != nil || len(addrs) != 1 || addrs[0].String() != "100.76.35.70" { + t.Fatalf("got %v %v", addrs, err) + } + if _, err := r.LookupNetIP(ctx, "ip4", "example.com"); err == nil { + t.Error("answered a name outside the zone over TCP") + } +} diff --git a/internal/tailnet/tailnet.go b/internal/tailnet/tailnet.go index ca644e8..463fb7d 100644 --- a/internal/tailnet/tailnet.go +++ b/internal/tailnet/tailnet.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "net/netip" + "net/url" "strings" "time" ) @@ -23,6 +24,10 @@ const OperatorFix = `sudo tailscale set --operator="$USER"` var ErrChanged = errors.New("tailnet: the Serve config changed underneath grove") +// ErrNotAccepted means tailscaled took the file but the device never did, +// usually a phone with Tailscale asleep in the background. +var ErrNotAccepted = errors.New("tailnet: the device did not accept the file") + type Status struct { Running bool Tailnet string @@ -30,10 +35,35 @@ type Status struct { Host string IPv4 netip.Addr IPv6 netip.Addr + // The other devices on the tailnet, which grove trust fetches roots from + // and sends this machine's root to. + Peers []Peer +} + +type Peer struct { + // Tailscale's stable node ID, which is what Taildrop addresses. + ID string + // The first label of the peer's MagicDNS name, as Status.Host is for self. + Host string + OS string + IPv4 netip.Addr + Online bool +} + +// Peer finds a device by the name it has on the tailnet. +func (s Status) Peer(host string) (Peer, bool) { + host = strings.ToLower(host) + for _, p := range s.Peers { + if p.Host == host { + return p, true + } + } + return Peer{}, false } type Client struct { - http *http.Client + http *http.Client + transfer *http.Client } func New(socket string) *Client { @@ -41,14 +71,18 @@ func New(socket string) *Client { return &Client{} } dialer := net.Dialer{Timeout: 2 * time.Second} - return &Client{http: &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return dialer.DialContext(ctx, "unix", socket) - }, + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "unix", socket) }, - }} + } + return &Client{ + http: &http.Client{Timeout: 5 * time.Second, Transport: transport}, + // A Taildrop send holds the request open until the device has the + // file, which a phone with Tailscale in the background can take far + // longer than five seconds to do. The caller's context bounds it. + transfer: &http.Client{Transport: transport}, + } } func (c *Client) Status(ctx context.Context) (Status, error) { @@ -59,6 +93,13 @@ func (c *Client) Status(ctx context.Context) (Status, error) { DNSName string TailscaleIPs []string } + Peer map[string]struct { + ID string + DNSName string + OS string + Online bool + TailscaleIPs []string + } } body, _, err := c.get(ctx, "status") if err != nil { @@ -72,8 +113,7 @@ func (c *Client) Status(ctx context.Context) (Status, error) { s.Tailnet = raw.CurrentTailnet.Name } if raw.Self != nil { - label, _, _ := strings.Cut(strings.TrimSuffix(raw.Self.DNSName, "."), ".") - s.Host = strings.ToLower(label) + s.Host = firstLabel(raw.Self.DNSName) for _, ip := range raw.Self.TailscaleIPs { addr, err := netip.ParseAddr(ip) switch { @@ -85,9 +125,57 @@ func (c *Client) Status(ctx context.Context) (Status, error) { } } } + for _, p := range raw.Peer { + peer := Peer{ID: p.ID, Host: firstLabel(p.DNSName), OS: p.OS, Online: p.Online} + for _, ip := range p.TailscaleIPs { + if addr, err := netip.ParseAddr(ip); err == nil && addr.Is4() { + peer.IPv4 = addr + break + } + } + if peer.Host != "" { + s.Peers = append(s.Peers, peer) + } + } return s, nil } +func firstLabel(dnsName string) string { + label, _, _ := strings.Cut(strings.TrimSuffix(dnsName, "."), ".") + return strings.ToLower(label) +} + +// SendFile delivers body to a device over Taildrop, which Tailscale encrypts +// and authenticates end to end, so the device knows which machine sent it. +func (c *Client) SendFile(ctx context.Context, to Peer, name string, body []byte) error { + if c.http == nil { + return ErrUnavailable + } + target := "http://local-tailscaled.sock/localapi/v0/file-put/" + url.PathEscape(to.ID) + "/" + url.PathEscape(name) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, target, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Sec-Tailscale", "localapi") + req.ContentLength = int64(len(body)) + resp, err := c.transfer.Do(req) + switch { + case err != nil && errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("%w: %s", ErrNotAccepted, to.Host) + case err != nil: + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + switch { + case resp.StatusCode == http.StatusForbidden: + return fmt.Errorf("%w: %s", ErrDenied, strings.TrimSpace(string(msg))) + case resp.StatusCode/100 != 2: + return fmt.Errorf("tailnet: sending %s to %s: %s: %s", name, to.Host, resp.Status, strings.TrimSpace(string(msg))) + } + return nil +} + // Preserve unknown handlers when updating the Serve config. type ServeConfig struct { doc map[string]json.RawMessage diff --git a/internal/tailnet/tailnet_test.go b/internal/tailnet/tailnet_test.go index e355b7a..e89edb3 100644 --- a/internal/tailnet/tailnet_test.go +++ b/internal/tailnet/tailnet_test.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/grove-sh/cli/internal/tailnet" ) @@ -24,6 +25,12 @@ type fakeTailscaled struct { serve string etag int denyWrite bool + // What Taildrop was handed: the path names the device and the file. + sentPath string + sentBody string + // A phone that never takes the file: the request is held until the client + // gives up. + neverAccept bool } func (f *fakeTailscaled) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -54,6 +61,21 @@ func (f *fakeTailscaled) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Etag", f.tag()) io.WriteString(w, f.serve) default: + if strings.HasPrefix(r.URL.Path, "/localapi/v0/file-put/") && r.Method == http.MethodPut { + if f.neverAccept { + f.mu.Unlock() + <-r.Context().Done() + f.mu.Lock() + return + } + if f.denyWrite { + http.Error(w, "file access denied", http.StatusForbidden) + return + } + body, _ := io.ReadAll(r.Body) + f.sentPath, f.sentBody = r.URL.EscapedPath(), string(body) + return + } http.NotFound(w, r) } } @@ -260,3 +282,61 @@ func TestShareRecordRoundTrips(t *testing.T) { t.Errorf("unshared zone %q", zone) } } + +func TestStatusListsPeers(t *testing.T) { + c := startFake(t, &fakeTailscaled{status: `{ + "BackendState": "Running", + "Self": {"DNSName": "regulus.tail0af9ab.ts.net."}, + "Peer": { + "nodekey:a": {"ID": "nMGCzXpjyW11CNTRL", "DNSName": "pixel-9.tail0af9ab.ts.net.", "OS": "android", "Online": true, "TailscaleIPs": ["fd7a::1", "100.70.52.30"]}, + "nodekey:b": {"ID": "neHR65UvQX11CNTRL", "DNSName": "Capella.tail0af9ab.ts.net.", "OS": "linux", "TailscaleIPs": ["100.76.35.70"]} + } + }`}) + s, err := c.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + phone, ok := s.Peer("pixel-9") + if !ok || phone.ID != "nMGCzXpjyW11CNTRL" || phone.OS != "android" || !phone.Online || phone.IPv4.String() != "100.70.52.30" { + t.Errorf("pixel-9: %+v ok=%v", phone, ok) + } + if capella, ok := s.Peer("CAPELLA"); !ok || capella.Host != "capella" || capella.Online { + t.Errorf("capella: %+v ok=%v", capella, ok) + } + if _, ok := s.Peer("regulus"); ok { + t.Error("found this machine among its own peers") + } +} + +func TestSendFileGoesToTheDeviceByStableID(t *testing.T) { + f := &fakeTailscaled{} + c := startFake(t, f) + phone := tailnet.Peer{ID: "nMGCzXpjyW11CNTRL", Host: "pixel-9"} + if err := c.SendFile(context.Background(), phone, "grove-regulus-root.crt", []byte("PEM")); err != nil { + t.Fatal(err) + } + if f.sentPath != "/localapi/v0/file-put/nMGCzXpjyW11CNTRL/grove-regulus-root.crt" || f.sentBody != "PEM" { + t.Errorf("sent %q %q", f.sentPath, f.sentBody) + } + + f.denyWrite = true + if err := c.SendFile(context.Background(), phone, "x.crt", nil); !errors.Is(err, tailnet.ErrDenied) { + t.Errorf("denied: got %v, want ErrDenied", err) + } +} + +// A device that does not take the file is a different failure from tailscaled +// not answering, and taking longer than the status timeout is no failure at all. +func TestSendFileToADeviceThatNeverAccepts(t *testing.T) { + c := startFake(t, &fakeTailscaled{neverAccept: true}) + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + start := time.Now() + err := c.SendFile(ctx, tailnet.Peer{ID: "n1", Host: "pixel-9"}, "x.crt", []byte("PEM")) + if !errors.Is(err, tailnet.ErrNotAccepted) { + t.Fatalf("got %v, want ErrNotAccepted", err) + } + if waited := time.Since(start); waited < 5500*time.Millisecond { + t.Errorf("gave up after %v, before the caller's deadline; the status timeout is cutting sends short", waited) + } +} From 1cec7e3f8a842be3dfcd979474ce45fba8a33ea8 Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Sun, 1 Nov 2026 13:24:40 -0500 Subject: [PATCH 3/3] Respect foreground Serve sessions and give Node peer roots - Port 443 held by a foreground tailscale serve is left alone - NODE_EXTRA_CA_CERTS carries trusted peer roots alongside this one --- CLAUDE.md | 4 +- cmd/grove/trust.go | 10 +++- docs/content/reference/cli/trust.mdx | 2 + .../reference/environment-variables.mdx | 2 +- internal/tailnet/tailnet.go | 13 +++++ internal/tailnet/tailnet_test.go | 23 +++++++++ internal/trust/trust.go | 36 +++++++++++-- internal/trust/trust_test.go | 50 +++++++++++++++++++ 8 files changed, 132 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9f571b3..c3efee6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,9 +83,9 @@ The four places still naming `127.0.0.1` are the other side of the proxy, where **What install prints is tested by pasting it.** The `macos-443` job greps `^[[:space:]]+sudo ` out of `grove install`'s output, runs those lines, and asserts `pf sends 443 to`. So the two-space indent in `platform_*.go` and that phrasing in `internal/redirect` are load-bearing with no Go test covering either: a job reconstructing the instructions could pass while the real ones were wrong. It points `GROVE_STATE_DIR` at a path containing a space on purpose, because splitting those paths into the wrong number of arguments once passed CI without one. The `linux-443` job and the tail of `macos-443` take the other path, `uninstall --yes` then `install --yes`, so grove running the steps itself is covered too. -**Sharing moves hostnames one label down.** `grove share` writes `share.json` beside the CA, naming a tailnet and the machine's MagicDNS label, and from then on `tailnet.Zone` makes every hostname `..grov.site`, in the CLI (`zone()` in `cmd/grove/share.go`) and the daemon (`Config.Zone`) alike. The record is held rather than re-read from Tailscale on purpose: hostnames must not move when tailscaled stops or switches tailnets. The daemon's `sharing` loop asks tailscaled's local API every ten seconds, and only on the recorded tailnet does it bind the split DNS resolver on the tailnet address and claim tailnet 443 through Serve as a raw TCP forward to its own listener. It never takes 443 from another Serve handler (t3code's is the usual one), and it releases its own mapping on any other tailnet. It also re-reads `share.json` on every check and stands down when the record is gone or changed, since otherwise `share --off` would be undone within ten seconds by a daemon still holding the share it started with. Linux only: `platform.TailscaleSocket` is empty elsewhere. +**Sharing moves hostnames one label down.** `grove share` writes `share.json` beside the CA, naming a tailnet and the machine's MagicDNS label, and from then on `tailnet.Zone` makes every hostname `..grov.site`, in the CLI (`zone()` in `cmd/grove/share.go`) and the daemon (`Config.Zone`) alike. The record is held rather than re-read from Tailscale on purpose: hostnames must not move when tailscaled stops or switches tailnets. The daemon's `sharing` loop asks tailscaled's local API every ten seconds, and only on the recorded tailnet does it bind the split DNS resolver on the tailnet address and claim tailnet 443 through Serve as a raw TCP forward to its own listener. It never takes 443 from another Serve handler (t3code's is the usual one), and a foreground `tailscale serve` session holding 443 counts as another handler too: those live under `Foreground` in the Serve config, apart from the top-level `TCP` map, and it releases its own mapping on any other tailnet. It also re-reads `share.json` on every check and stands down when the record is gone or changed, since otherwise `share --off` would be undone within ten seconds by a daemon still holding the share it started with. Linux only: `platform.TailscaleSocket` is empty elsewhere. -**Trust between machines rides on the tailnet's own authentication.** The proxy answers `proxy.RootPath` on the zone's bare hostname with the root, and counts that host as routed so it gets a leaf. `grove trust ` fetches it with certificate verification off, which is the one place grove does that: nothing trusts the peer's root yet, so the stand-in is dialling the peer's tailnet IP from tailscaled's peer list, never DNS, which WireGuard ties to that node's key. It then insists the served leaf chains to the fetched root. Peer roots are kept under `peers/` in the state directory so `--remove` and `uninstall` undo exactly what trust did. `cmd/grove`'s `TestMain` points `GROVE_STATE_DIR` at a temp directory, because a maintainer's own `share.json` would otherwise rename every hostname the suite expects. +**Trust between machines rides on the tailnet's own authentication.** The proxy answers `proxy.RootPath` on the zone's bare hostname with the root, and counts that host as routed so it gets a leaf. `grove trust ` fetches it with certificate verification off, which is the one place grove does that: nothing trusts the peer's root yet, so the stand-in is dialling the peer's tailnet IP from tailscaled's peer list, never DNS, which WireGuard ties to that node's key. It then insists the served leaf chains to the fetched root. Peer roots are kept under `peers/` in the state directory so `--remove` and `uninstall` undo exactly what trust did, and every change rewrites `node-roots.pem`, this machine's root plus the peers', which is what `NODE_EXTRA_CA_CERTS` points at once it exists, since Node reads no system store. `cmd/grove`'s `TestMain` points `GROVE_STATE_DIR` at a temp directory, because a maintainer's own `share.json` would otherwise rename every hostname the suite expects. `grov.site` is real wildcard DNS pointing at `platform.Address`, so there are no hosts-file entries to manage. `*.v4.grov.site` also points there and is kept for the 0.3.0 betas, which shipped while `*.grov.site` still answered `127.0.0.1`: deleting it would break a published version. The domain is not a config key yet: `defaultDomain` in `cmd/grove/main.go` is a constant, and nothing in `internal/config` reads a domain. diff --git a/cmd/grove/trust.go b/cmd/grove/trust.go index bde5d85..738f16c 100644 --- a/cmd/grove/trust.go +++ b/cmd/grove/trust.go @@ -225,7 +225,12 @@ func trustPeer(out io.Writer, client *tailnet.Client, stateDir, host string) err if err := os.WriteFile(peerFile(stateDir, host), raw, 0o644); err != nil { return err } - fmt.Fprintf(out, "\nhttps://.%s.%s now opens here without a warning.\n", host, defaultDomain) + if err := trust.WriteNodeBundle(stateDir); err != nil { + return err + } + fmt.Fprintf(out, "\nhttps://.%s.%s now opens here without a warning. Processes started\n"+ + "before this keep the trust they began with, so restart a dev server that calls\n"+ + "%s for it to trust the new root.\n", host, defaultDomain, host) return nil } @@ -302,6 +307,9 @@ func untrustPeer(out io.Writer, stateDir, host string) error { if err := os.Remove(peerFile(stateDir, host)); err != nil { return err } + if err := trust.WriteNodeBundle(stateDir); err != nil { + return err + } fmt.Fprintf(out, "stopped trusting %s's root %s\n", host, fingerprint(cert)) return nil } diff --git a/docs/content/reference/cli/trust.mdx b/docs/content/reference/cli/trust.mdx index a383330..9ae4c7d 100644 --- a/docs/content/reference/cli/trust.mdx +++ b/docs/content/reference/cli/trust.mdx @@ -15,6 +15,8 @@ Each machine that runs grove has its own root, so opening another machine's host With no arguments, it prints this machine's root fingerprint and the roots it trusts from other machines. Linux only for now, apart from that listing. +Browsers pick up a new root right away. Commands grove runs get it through `NODE_EXTRA_CA_CERTS` and the system bundle, so a dev server started before `grove trust` needs restarting before it trusts that machine. + The roots it adds are kept under `peers/` in the state directory, so `--remove` and [`grove uninstall`](/reference/cli/uninstall) take back exactly those and nothing trusted some other way. ## Flags diff --git a/docs/content/reference/environment-variables.mdx b/docs/content/reference/environment-variables.mdx index 7d5d796..cb5cc27 100644 --- a/docs/content/reference/environment-variables.mdx +++ b/docs/content/reference/environment-variables.mdx @@ -13,7 +13,7 @@ description: Every GROVE_ variable, the ones grove sets in the commands it runs | `GROVE_PORT` | The bound entry's port. Only when an entry is bound. | | `GROVE_HOST` | The bound route's hostname. Not set for a `[ports.x]` entry. | | `GROVE_URL` | `https://` and that hostname. | -| `NODE_EXTRA_CA_CERTS` | Grove's root, for node. Left alone if you already set it. | +| `NODE_EXTRA_CA_CERTS` | Grove's root and any trusted peer roots, for node. Left alone if you already set it. | | `REQUESTS_CA_BUNDLE` | A bundle carrying grove's root, for python requests. Left alone if set. | | `DENO_CERT` | The same bundle, for deno. Left alone if set. | diff --git a/internal/tailnet/tailnet.go b/internal/tailnet/tailnet.go index 463fb7d..d05f96b 100644 --- a/internal/tailnet/tailnet.go +++ b/internal/tailnet/tailnet.go @@ -258,6 +258,19 @@ func (cfg *ServeConfig) tcp() (map[string]json.RawMessage, error) { } func (cfg *ServeConfig) Port443(target string) (Holder, error) { + // Foreground sessions own their listeners too, even though grove only + // writes background mappings. + var foreground map[string]struct{ TCP map[string]json.RawMessage } + if raw, ok := cfg.doc["Foreground"]; ok { + if err := json.Unmarshal(raw, &foreground); err != nil { + return Other, fmt.Errorf("tailnet: reading foreground Serve handlers: %w", err) + } + } + for _, session := range foreground { + if _, ok := session.TCP["443"]; ok { + return Other, nil + } + } ports, err := cfg.tcp() if err != nil { return Other, err diff --git a/internal/tailnet/tailnet_test.go b/internal/tailnet/tailnet_test.go index e89edb3..089e905 100644 --- a/internal/tailnet/tailnet_test.go +++ b/internal/tailnet/tailnet_test.go @@ -207,6 +207,29 @@ func TestReleaseLeavesSomeoneElsesPort(t *testing.T) { } } +func TestForeground443IsHeld(t *testing.T) { + for _, handler := range []string{`{"HTTPS":true}`, `{"TCPForward":"127.0.0.4:443"}`} { + body := `{"Foreground":{"session":{"TCP":{"443":` + handler + `}}}}` + cfg, err := tailnet.ParseServeConfig([]byte(body), "") + if err != nil { + t.Fatal(err) + } + if holder, err := cfg.Port443(target); err != nil || holder != tailnet.Other { + t.Fatalf("holder=%v err=%v, want another holder", holder, err) + } + if changed, err := cfg.Claim443(target); changed || !tailnet.IsHeld(err) { + t.Fatalf("claim: changed=%v err=%v, want held", changed, err) + } + if changed, err := cfg.Release443(target); changed || err != nil { + t.Fatalf("release: changed=%v err=%v, want untouched", changed, err) + } + got, _ := cfg.MarshalJSON() + if string(got) != body { + t.Errorf("foreground config changed: %s", got) + } + } +} + func TestWriteAfterSomeoneElsesChangeIsRefused(t *testing.T) { f := &fakeTailscaled{serve: `{}`} c := startFake(t, f) diff --git a/internal/trust/trust.go b/internal/trust/trust.go index 11d0558..26e3fe3 100644 --- a/internal/trust/trust.go +++ b/internal/trust/trust.go @@ -15,10 +15,36 @@ import ( ) const ( - RootFile = "root.crt" - BundleFile = "ca-bundle.pem" + RootFile = "root.crt" + BundleFile = "ca-bundle.pem" + NodeBundleFile = "node-roots.pem" ) +// WriteNodeBundle keeps Node's additional roots independent of the system +// bundle, which may not contain this machine's root yet. +func WriteNodeBundle(stateDir string) error { + roots, err := os.ReadFile(filepath.Join(stateDir, RootFile)) + if err != nil { + return err + } + peers, err := filepath.Glob(filepath.Join(stateDir, "peers", "*.crt")) + if err != nil { + return err + } + for _, peer := range peers { + root, err := os.ReadFile(peer) + if err != nil { + return err + } + roots = append(append(roots, '\n'), root...) + } + dest := filepath.Join(stateDir, NodeBundleFile) + if err := os.WriteFile(dest+".tmp", roots, 0o644); err != nil { + return err + } + return os.Rename(dest+".tmp", dest) +} + // Install shells out to sudo on Linux and macOS, so callers warn first. func Install(root *x509.Certificate) error { return truststore.Install(root, truststore.WithFirefox(), truststore.WithJava()) @@ -172,8 +198,10 @@ func Env(stateDir string) []string { out = append(out, name+"="+path) } - // Node compiles in its own roots, and this variable adds rather than - // replaces, so it takes the root alone. + // Node compiles in its own roots. Add the local and trusted peer roots. + if node := existing(filepath.Join(stateDir, NodeBundleFile)); node != "" { + root = node + } add("NODE_EXTRA_CA_CERTS", root) // These replace a trust store rather than adding to it, so they need a whole diff --git a/internal/trust/trust_test.go b/internal/trust/trust_test.go index c57844e..96cd396 100644 --- a/internal/trust/trust_test.go +++ b/internal/trust/trust_test.go @@ -1,6 +1,7 @@ package trust_test import ( + "crypto/x509" "os" "path/filepath" "runtime" @@ -12,6 +13,55 @@ import ( "github.com/grove-sh/cli/internal/trust" ) +func TestNodeBundleTracksPeerTrust(t *testing.T) { + dir := t.TempDir() + local, err := ca.OpenOrCreate(dir) + if err != nil { + t.Fatal(err) + } + peer, err := ca.OpenOrCreate(t.TempDir()) + if err != nil { + t.Fatal(err) + } + peerDir := filepath.Join(dir, "peers") + if err := os.Mkdir(peerDir, 0o755); err != nil { + t.Fatal(err) + } + peerPath := filepath.Join(peerDir, "capella.crt") + if err := os.WriteFile(peerPath, peer.RootPEM(), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("NODE_EXTRA_CA_CERTS", "") + os.Unsetenv("NODE_EXTRA_CA_CERTS") + for _, removed := range []bool{false, true} { + if removed { + if err := os.Remove(peerPath); err != nil { + t.Fatal(err) + } + } + if err := trust.WriteNodeBundle(dir); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, trust.NodeBundleFile) + if !slices.Contains(trust.Env(dir), "NODE_EXTRA_CA_CERTS="+path) { + t.Fatal("Node environment does not use the roots bundle") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(raw) + if _, err := local.Certificate().Verify(x509.VerifyOptions{Roots: pool}); err != nil { + t.Fatalf("local root missing: %v", err) + } + _, err = peer.Certificate().Verify(x509.VerifyOptions{Roots: pool}) + if (err != nil) != removed { + t.Fatalf("peer removed=%v, verification error=%v", removed, err) + } + } +} + // After a real install the OS trust store rewrites the system bundle, so grove // keeps no copy. A freshly generated root is not in there. func TestSystemBundleDoesNotCarryAnUninstalledRoot(t *testing.T) {