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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), `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:

| | |
|-|-|
Expand All @@ -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 |

Expand Down Expand Up @@ -82,6 +83,10 @@ 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 `<context>.<host>.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 <host>` 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.

## Conventions in this codebase
Expand Down
4 changes: 2 additions & 2 deletions cmd/grove/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
16 changes: 15 additions & 1 deletion cmd/grove/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/signal"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion cmd/grove/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions cmd/grove/doctor_share.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading