diff --git a/cmd/lerd/main.go b/cmd/lerd/main.go index ab4dfd24..cc176ecb 100644 --- a/cmd/lerd/main.go +++ b/cmd/lerd/main.go @@ -208,6 +208,7 @@ func main() { root.AddCommand(cmd) } root.AddCommand(cli.NewShareCmd()) + root.AddCommand(cli.NewShareToolCmd()) root.AddCommand(cli.NewDomainCmd()) root.AddCommand(cli.NewGroupCmd()) root.AddCommand(cli.NewWorkspaceCmd()) diff --git a/docs/configuration.md b/docs/configuration.md index 6a26d903..4bae75c6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,11 @@ node: # / node:unmanage; honoured on lerd update so an opt-out # is not undone. Omitted on configs predating it, which # fall back to whatever shims are on disk. +share: + default_tool: cloudflare # optional. Tunnel tool lerd share uses when no tool + # flag is given: ngrok, cloudflare, expose, serveo or + # localhost-run. Written by lerd share:tool; omitted + # (the default) means auto-detect. nginx: http_port: 80 https_port: 443 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index dd5eb0e0..2faa780b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,6 +69,8 @@ Setup steps include common tasks (composer install, npm install, lerd env) plus | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | | `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | +| `lerd share --domain ` | Expose the site on your own Cloudflare-managed hostname via a named tunnel (implies Cloudflare Tunnel) | +| `lerd share:tool [tool]` | Show or set the default tunnel tool for `lerd share` (`auto` restores auto-detection) | | `lerd secure [name]` | Issue a mkcert TLS cert and enable HTTPS, updates `APP_URL` in `.env` | | `lerd secure --renew [name]` | Reissue a secured site's TLS cert on demand, resetting its expiry | | `lerd unsecure [name]` | Remove TLS and switch back to HTTP, updates `APP_URL` in `.env` | diff --git a/docs/usage/sites.md b/docs/usage/sites.md index f9d9a8cd..f22988a9 100644 --- a/docs/usage/sites.md +++ b/docs/usage/sites.md @@ -15,7 +15,7 @@ | `lerd domain list` | List all domains for the current site | | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | -| `lerd share [name]` | Expose the site publicly via ngrok or Expose (auto-detected) | +| `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | | `lerd secure [name]` | Issue a mkcert TLS cert and enable HTTPS, updates `APP_URL` in `.env` | | `lerd unsecure [name]` | Remove TLS and switch back to HTTP, updates `APP_URL` in `.env` | | `lerd pause [name]` | Pause a site: stop its workers and replace the vhost with a landing page | @@ -403,6 +403,26 @@ Lerd automatically creates a subdomain for each `git worktree` checkout. See [Gi | `lerd share --expose` | Force Expose | | `lerd share --localhost-run` | Force localhost.run (SSH, no signup) | | `lerd share --serveo` | Force serveo.net (SSH, no signup) | +| `lerd share --domain ` | Serve on your own Cloudflare-managed hostname (implies Cloudflare Tunnel) | +| `lerd share:tool [tool]` | Show or set the default tunnel tool (`ngrok`, `cloudflare`, `expose`, `serveo`, `localhost-run`, or `auto`) | + +### Default tunnel tool + +Auto-detection picks the first installed tool, which may not be the one you want. `lerd share:tool cloudflare` pins the default; from then on a bare `lerd share` uses Cloudflare Tunnel even with ngrok installed. A tool flag still overrides the default per run, and `lerd share:tool auto` restores auto-detection. + +### Sharing on your own domain + +Quick tunnels hand out a fresh random `trycloudflare.com` URL on every run. When you need a stable URL (sending a client the same link twice, webhook or OAuth callback targets), pass `--domain` with a hostname whose DNS is managed by Cloudflare: + +```bash +lerd share --domain dev.example.com +``` + +Custom hostnames are a Cloudflare Tunnel feature, so `--domain` selects that tool on its own. You never need `--cloudflare` alongside it, and it wins over a different default set with `lerd share:tool`. Combining it with another tool flag is rejected rather than silently ignored. + +On the first run cloudflared opens a browser window to authorize your Cloudflare account (a one-time login that writes `~/.cloudflared/cert.pem`). lerd then creates a named tunnel called `lerd-`, routes the hostname to it with a CNAME record, and starts the tunnel. Later runs reuse the same tunnel and hostname, so the URL never changes. Re-routing a hostname that already points at the same tunnel is a no-op; if the record exists but points somewhere else, lerd leaves it alone and prints a note asking you to check it. + +A freshly created DNS record takes a moment to become visible. If you open the URL in the first seconds and your resolver caches the miss, it can keep answering NXDOMAIN for up to 30 minutes even though the tunnel is healthy. A local reverse proxy rewrites the `Host` header to the site's domain so nginx routes to the correct vhost. Response `Location` headers and HTML/CSS/JS/JSON body references to the local domain are also rewritten to the public tunnel URL, so redirects and asset links work correctly in the browser. diff --git a/internal/cli/share.go b/internal/cli/share.go index fe8e2580..52f49710 100644 --- a/internal/cli/share.go +++ b/internal/cli/share.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "crypto/tls" + "encoding/json" "fmt" "io" "net" @@ -28,6 +29,7 @@ func NewShareCmd() *cobra.Command { var useExpose bool var useServeo bool var useLocalhostRun bool + var domain string cmd := &cobra.Command{ Use: "share [site]", @@ -41,10 +43,17 @@ Supported tools: cloudflared https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ expose https://expose.dev localhost.run free SSH tunnel, no account needed (--localhost-run) - serveo.net free SSH tunnel, no account needed (--serveo)`, + serveo.net free SSH tunnel, no account needed (--serveo) + +A default tool can be set with "lerd share:tool"; flags override it per run. + +--domain selects Cloudflare Tunnel on its own: a named tunnel is created (or +reused) and the given hostname is routed to it, so the site is served on your +own domain instead of a random trycloudflare.com URL. The domain's DNS must be +managed by Cloudflare.`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return runShare(args, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun) + return runShare(args, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain) }, } @@ -53,6 +62,7 @@ Supported tools: cmd.Flags().BoolVar(&useExpose, "expose", false, "Use Expose") cmd.Flags().BoolVar(&useServeo, "serveo", false, "Use serveo.net (SSH, no signup)") cmd.Flags().BoolVar(&useLocalhostRun, "localhost-run", false, "Use localhost.run (SSH, no signup)") + cmd.Flags().StringVar(&domain, "domain", "", "Serve on your own Cloudflare-managed hostname (implies Cloudflare Tunnel)") return cmd } @@ -68,9 +78,10 @@ const ( type shareTool struct { mode shareMode sshHost string // only for shareModeSSH + domain string // only for shareModeCloudflare: user's own hostname (named tunnel) } -func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool) error { +func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool, domain string) error { site, err := resolveShareSite(args) if err != nil { return err @@ -85,7 +96,7 @@ func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useL port = 80 } - tool, err := pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun) + tool, err := pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain, cfg.Share.DefaultTool) if err != nil { return err } @@ -115,8 +126,18 @@ func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useL return fmt.Errorf("starting local proxy: %w", err) } defer stop() - cmd = exec.Command("cloudflared", "tunnel", - "--url", fmt.Sprintf("http://127.0.0.1:%d", proxyPort)) + if tool.domain != "" { + tunnelName, err := ensureCloudflareTunnel(site.Name, tool.domain) + if err != nil { + return err + } + fmt.Printf("Public URL: https://%s\n\n", tool.domain) + cmd = exec.Command("cloudflared", "tunnel", "run", + "--url", fmt.Sprintf("http://127.0.0.1:%d", proxyPort), tunnelName) + } else { + cmd = exec.Command("cloudflared", "tunnel", + "--url", fmt.Sprintf("http://127.0.0.1:%d", proxyPort)) + } case shareModeSSH: httpsPort := cfg.Nginx.HTTPSPort if httpsPort == 0 { @@ -173,7 +194,7 @@ func resolveShareSite(args []string) (*config.Site, error) { return ensureSiteForCwd() } -func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool) (*shareTool, error) { +func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool, domain, defaultTool string) (*shareTool, error) { count := 0 for _, f := range []bool{useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun} { if f { @@ -183,22 +204,50 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu if count > 1 { return nil, fmt.Errorf("only one of --ngrok, --cloudflare, --expose, --serveo, --localhost-run may be specified") } + fromDefault := false + + // --domain only means anything for Cloudflare Tunnel, so it picks the tool + // itself and outranks the configured default. Another explicit tool flag is + // a real conflict, so say so instead of silently overriding it. + if domain != "" { + if count > 0 && !useCloudflare { + return nil, fmt.Errorf("--domain only works with Cloudflare Tunnel, drop the other tool flag") + } + useCloudflare = true + } else if count == 0 && defaultTool != "" { + // No tool flag: fall back to the configured default before auto-detecting. + fromDefault = true + switch defaultTool { + case "ngrok": + useNgrok = true + case "cloudflare": + useCloudflare = true + case "expose": + useExpose = true + case "serveo": + useServeo = true + case "localhost-run": + useLocalhostRun = true + default: + return nil, fmt.Errorf("unknown default share tool %q in config: run \"lerd share:tool\" to fix it", defaultTool) + } + } if useNgrok { if _, err := exec.LookPath("ngrok"); err != nil { - return nil, fmt.Errorf("ngrok not found in PATH — install it from https://ngrok.com/download") + return nil, fmt.Errorf("ngrok not found in PATH — install it from https://ngrok.com/download%s", defaultToolHint(fromDefault)) } return &shareTool{mode: shareModeNgrok}, nil } if useCloudflare { if _, err := exec.LookPath("cloudflared"); err != nil { - return nil, fmt.Errorf("cloudflared not found in PATH — install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") + return nil, fmt.Errorf("cloudflared not found in PATH — install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/%s", defaultToolHint(fromDefault)) } - return &shareTool{mode: shareModeCloudflare}, nil + return &shareTool{mode: shareModeCloudflare, domain: domain}, nil } if useExpose { if _, err := exec.LookPath("expose"); err != nil { - return nil, fmt.Errorf("expose not found in PATH — install it from https://expose.dev") + return nil, fmt.Errorf("expose not found in PATH — install it from https://expose.dev%s", defaultToolHint(fromDefault)) } return &shareTool{mode: shareModeExpose}, nil } @@ -227,6 +276,99 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu return nil, fmt.Errorf("no tunnel tool found — install ngrok (https://ngrok.com/download), cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/), or Expose (https://expose.dev), or ensure ssh is in PATH") } +// defaultToolHint explains that a missing binary was picked by the configured +// default rather than by a flag, so a bare "lerd share" does not look broken. +func defaultToolHint(fromDefault bool) string { + if !fromDefault { + return "" + } + return "\nIt is your \"lerd share:tool\" default; run \"lerd share:tool auto\" to go back to auto-detection" +} + +// cloudflaredCertPath returns the origin certificate cloudflared writes after +// "tunnel login". TUNNEL_ORIGIN_CERT overrides it, mirroring cloudflared itself. +func cloudflaredCertPath() string { + if p := os.Getenv("TUNNEL_ORIGIN_CERT"); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cloudflared", "cert.pem") +} + +// ensureCloudflareTunnel makes sure a named tunnel exists for the site and that +// domain routes to it, logging in interactively first if needed. +// Returns the tunnel name to pass to "cloudflared tunnel run". +func ensureCloudflareTunnel(siteName, domain string) (string, error) { + if _, err := os.Stat(cloudflaredCertPath()); err != nil { + fmt.Println("cloudflared is not logged in yet, a browser window will open to authorize it.") + login := exec.Command("cloudflared", "tunnel", "login") + login.Stdin = os.Stdin + login.Stdout = os.Stdout + login.Stderr = os.Stderr + if err := login.Run(); err != nil { + return "", fmt.Errorf("cloudflared tunnel login: %w", err) + } + if _, err := os.Stat(cloudflaredCertPath()); err != nil { + return "", fmt.Errorf("cloudflared login finished but %s is still missing", cloudflaredCertPath()) + } + } + + name := "lerd-" + siteName + out, err := exec.Command("cloudflared", "tunnel", "create", name).CombinedOutput() + reused := err != nil && strings.Contains(string(out), "already exists") + if err != nil && !reused { + return "", fmt.Errorf("cloudflared tunnel create %s: %w\n%s", name, err, out) + } + if reused { + if err := checkTunnelCredentials(name); err != nil { + return "", err + } + } + + // Re-routing a hostname that already points here is a no-op, but cloudflared + // refuses to overwrite a record aimed elsewhere, so tolerate that and let the + // user verify where it points. + out, err = exec.Command("cloudflared", "tunnel", "route", "dns", name, domain).CombinedOutput() + if err != nil { + if strings.Contains(string(out), "already exists") || strings.Contains(string(out), "already configured") { + fmt.Printf("DNS record for %s already exists, make sure it CNAMEs to tunnel %q.\n", domain, name) + } else { + return "", fmt.Errorf("cloudflared tunnel route dns %s %s: %w\n%s", name, domain, err, out) + } + } else if strings.Contains(string(out), "Added CNAME") { + fmt.Printf("Routed %s to tunnel %q. The record is new, so give it a moment: opening it too early can leave your resolver caching the miss for up to 30 minutes.\n", domain, name) + } + return name, nil +} + +// checkTunnelCredentials verifies the credentials file for an existing tunnel is +// present locally. Without it "tunnel run" dies with an opaque cloudflared error, +// which is what happens when the tunnel was created on another machine. +func checkTunnelCredentials(name string) error { + out, err := exec.Command("cloudflared", "tunnel", "list", "--name", name, "--output", "json").Output() + if err != nil { + return nil // cannot tell, let "tunnel run" be the judge + } + var tunnels []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(out, &tunnels); err != nil || len(tunnels) == 0 { + return nil + } + cert := cloudflaredCertPath() + if cert == "" { + return nil + } + creds := filepath.Join(filepath.Dir(cert), tunnels[0].ID+".json") + if _, err := os.Stat(creds); err == nil { + return nil + } + return fmt.Errorf("tunnel %q exists on your Cloudflare account but its credentials file is missing at %s: copy it from the machine that created the tunnel, or run \"cloudflared tunnel delete %s\" and share again", name, creds, name) +} + // startHostProxy starts a local HTTP reverse proxy on a random loopback port. // It rewrites the Host header to domain before forwarding to nginx, // so nginx can route the request to the correct vhost. diff --git a/internal/cli/share_test.go b/internal/cli/share_test.go index 59fe2c54..68700f24 100644 --- a/internal/cli/share_test.go +++ b/internal/cli/share_test.go @@ -66,7 +66,7 @@ func TestPickShareTool_mutualExclusion(t *testing.T) { {true, false, false, false, true}, // ngrok + localhost-run } for _, p := range pairs { - _, err := pickShareTool(p[0], p[1], p[2], p[3], p[4]) + _, err := pickShareTool(p[0], p[1], p[2], p[3], p[4], "", "") if err == nil { t.Errorf("pickShareTool%v: expected mutual-exclusion error, got nil", p) continue @@ -79,7 +79,7 @@ func TestPickShareTool_mutualExclusion(t *testing.T) { func TestPickShareTool_explicitNgrok_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok")) - tool, err := pickShareTool(true, false, false, false, false) + tool, err := pickShareTool(true, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -90,7 +90,7 @@ func TestPickShareTool_explicitNgrok_present(t *testing.T) { func TestPickShareTool_explicitNgrok_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(true, false, false, false, false) + _, err := pickShareTool(true, false, false, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -101,7 +101,7 @@ func TestPickShareTool_explicitNgrok_absent(t *testing.T) { func TestPickShareTool_explicitCloudflare_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "cloudflared")) - tool, err := pickShareTool(false, true, false, false, false) + tool, err := pickShareTool(false, true, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -112,7 +112,7 @@ func TestPickShareTool_explicitCloudflare_present(t *testing.T) { func TestPickShareTool_explicitCloudflare_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, true, false, false, false) + _, err := pickShareTool(false, true, false, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -123,7 +123,7 @@ func TestPickShareTool_explicitCloudflare_absent(t *testing.T) { func TestPickShareTool_explicitExpose_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "expose")) - tool, err := pickShareTool(false, false, true, false, false) + tool, err := pickShareTool(false, false, true, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -134,7 +134,7 @@ func TestPickShareTool_explicitExpose_present(t *testing.T) { func TestPickShareTool_explicitExpose_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, false, true, false, false) + _, err := pickShareTool(false, false, true, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -144,7 +144,7 @@ func TestPickShareTool_explicitExpose_absent(t *testing.T) { } func TestPickShareTool_explicitServeo(t *testing.T) { - tool, err := pickShareTool(false, false, false, true, false) + tool, err := pickShareTool(false, false, false, true, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -157,7 +157,7 @@ func TestPickShareTool_explicitServeo(t *testing.T) { } func TestPickShareTool_explicitLocalhostRun(t *testing.T) { - tool, err := pickShareTool(false, false, false, false, true) + tool, err := pickShareTool(false, false, false, false, true, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -171,7 +171,7 @@ func TestPickShareTool_explicitLocalhostRun(t *testing.T) { func TestPickShareTool_autoDetect_ngrokFirst(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared", "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -182,7 +182,7 @@ func TestPickShareTool_autoDetect_ngrokFirst(t *testing.T) { func TestPickShareTool_autoDetect_cloudflareBeforeExpose(t *testing.T) { t.Setenv("PATH", fakeBin(t, "cloudflared", "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -193,7 +193,7 @@ func TestPickShareTool_autoDetect_cloudflareBeforeExpose(t *testing.T) { func TestPickShareTool_autoDetect_expose(t *testing.T) { t.Setenv("PATH", fakeBin(t, "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -204,7 +204,7 @@ func TestPickShareTool_autoDetect_expose(t *testing.T) { func TestPickShareTool_autoDetect_sshFallback(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -218,7 +218,7 @@ func TestPickShareTool_autoDetect_sshFallback(t *testing.T) { func TestPickShareTool_autoDetect_noTools(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, false, false, false, false) + _, err := pickShareTool(false, false, false, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -227,6 +227,347 @@ func TestPickShareTool_autoDetect_noTools(t *testing.T) { } } +func TestPickShareTool_domain_withExplicitCloudflare(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + tool, err := pickShareTool(false, true, false, false, false, "dev.example.com", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tool.mode != shareModeCloudflare { + t.Errorf("mode = %v, want shareModeCloudflare", tool.mode) + } + if tool.domain != "dev.example.com" { + t.Errorf("domain = %q, want dev.example.com", tool.domain) + } +} + +func TestPickShareTool_domain_withCloudflareDefault(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + tool, err := pickShareTool(false, false, false, false, false, "dev.example.com", "cloudflare") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tool.mode != shareModeCloudflare { + t.Errorf("mode = %v, want shareModeCloudflare", tool.mode) + } + if tool.domain != "dev.example.com" { + t.Errorf("domain = %q, want dev.example.com", tool.domain) + } +} + +func TestPickShareTool_domain_impliesCloudflare(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + // --domain is Cloudflare-only, so it selects the tool with no flag at all and + // outranks a configured default pointing somewhere else. + for _, defaultTool := range []string{"", "ngrok"} { + tool, err := pickShareTool(false, false, false, false, false, "dev.example.com", defaultTool) + if err != nil { + t.Errorf("default %q: unexpected error: %v", defaultTool, err) + continue + } + if tool.mode != shareModeCloudflare { + t.Errorf("default %q: mode = %v, want shareModeCloudflare", defaultTool, tool.mode) + } + if tool.domain != "dev.example.com" { + t.Errorf("default %q: domain = %q, want dev.example.com", defaultTool, tool.domain) + } + } +} + +func TestPickShareTool_domain_withOtherTool(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + for _, p := range [][4]bool{ + {true, false, false, false}, + {false, true, false, false}, + {false, false, true, false}, + {false, false, false, true}, + } { + _, err := pickShareTool(p[0], false, p[1], p[2], p[3], "dev.example.com", "") + if err == nil || !strings.Contains(err.Error(), "--domain only works with Cloudflare Tunnel") { + t.Errorf("pickShareTool%v: error = %v, want '--domain only works with Cloudflare Tunnel'", p, err) + } + } +} + +func TestPickShareTool_domain_cloudflaredAbsent(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := pickShareTool(false, true, false, false, false, "dev.example.com", "") + if err == nil || !strings.Contains(err.Error(), "cloudflared not found") { + t.Errorf("error = %v, want 'cloudflared not found'", err) + } +} + +func TestPickShareTool_defaultTool_used(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared", "expose", "ssh")) + cases := []struct { + defaultTool string + wantMode shareMode + wantSSHHost string + }{ + {"ngrok", shareModeNgrok, ""}, + {"cloudflare", shareModeCloudflare, ""}, + {"expose", shareModeExpose, ""}, + {"serveo", shareModeSSH, "serveo.net"}, + {"localhost-run", shareModeSSH, "localhost.run"}, + } + for _, c := range cases { + tool, err := pickShareTool(false, false, false, false, false, "", c.defaultTool) + if err != nil { + t.Errorf("default %q: unexpected error: %v", c.defaultTool, err) + continue + } + if tool.mode != c.wantMode { + t.Errorf("default %q: mode = %v, want %v", c.defaultTool, tool.mode, c.wantMode) + } + if tool.sshHost != c.wantSSHHost { + t.Errorf("default %q: sshHost = %q, want %q", c.defaultTool, tool.sshHost, c.wantSSHHost) + } + } +} + +func TestPickShareTool_defaultTool_flagOverrides(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + tool, err := pickShareTool(true, false, false, false, false, "", "cloudflare") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tool.mode != shareModeNgrok { + t.Errorf("mode = %v, want shareModeNgrok (flag beats default)", tool.mode) + } +} + +func TestPickShareTool_defaultTool_missingBinary_namesTheDefault(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + for _, c := range []struct{ defaultTool, wantBinary string }{ + {"ngrok", "ngrok"}, {"cloudflare", "cloudflared"}, {"expose", "expose"}, + } { + _, err := pickShareTool(false, false, false, false, false, "", c.defaultTool) + if err == nil { + t.Errorf("default %q: expected an error, got nil", c.defaultTool) + continue + } + if !strings.Contains(err.Error(), c.wantBinary+" not found") { + t.Errorf("default %q: error = %v, want %q not found", c.defaultTool, err, c.wantBinary) + } + if !strings.Contains(err.Error(), "lerd share:tool") { + t.Errorf("default %q: error should point at share:tool, got %v", c.defaultTool, err) + } + } +} + +func TestPickShareTool_explicitFlag_missingBinary_omitsTheHint(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := pickShareTool(true, false, false, false, false, "", "") + if err == nil { + t.Fatal("expected an error, got nil") + } + if strings.Contains(err.Error(), "share:tool") { + t.Errorf("an explicit --ngrok should not mention the default, got %v", err) + } +} + +func TestPickShareTool_defaultTool_unknown(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok")) + _, err := pickShareTool(false, false, false, false, false, "", "bogus") + if err == nil || !strings.Contains(err.Error(), "unknown default share tool") { + t.Errorf("error = %v, want 'unknown default share tool'", err) + } +} + +// ── ensureCloudflareTunnel ──────────────────────────────────────────────────── + +// fakeTunnelID is the id the fake cloudflared reports from "tunnel list", and so +// the stem of the credentials file ensureCloudflareTunnel looks for. +const fakeTunnelID = "11111111-2222-3333-4444-555555555555" + +// fakeCloudflared installs a fake cloudflared script and returns its log file. +// The script logs every invocation; createExit/routeExit control the exit codes +// and output of "tunnel create" / "tunnel route dns". "tunnel login" writes +// cert.pem under $HOME/.cloudflared. +func fakeCloudflared(t *testing.T, createOut string, createExit int, routeOut string, routeExit int) string { + t.Helper() + dir := t.TempDir() + logFile := filepath.Join(dir, "calls.log") + script := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +case "$1 $2" in +"tunnel login") : > "$HOME/.cloudflared/cert.pem";; +"tunnel create") echo %q; exit %d;; +"tunnel list") printf '[{"id":"%s","name":"%%s"}]\n' "$4";; +"tunnel route") echo %q; exit %d;; +esac +`, logFile, createOut, createExit, fakeTunnelID, routeOut, routeExit) + if err := os.WriteFile(filepath.Join(dir, "cloudflared"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + return logFile +} + +// certWithTunnelCredentials points TUNNEL_ORIGIN_CERT at a fresh cert with the +// credentials file for fakeTunnelID beside it, the state of a machine that +// created the tunnel itself. +func certWithTunnelCredentials(t *testing.T) { + t.Helper() + dir := newCertDir(t) + if err := os.WriteFile(filepath.Join(dir, fakeTunnelID+".json"), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } +} + +// certWithoutTunnelCredentials leaves the credentials file out, the state of a +// machine where the tunnel was created somewhere else. +func certWithoutTunnelCredentials(t *testing.T) { + t.Helper() + newCertDir(t) +} + +func newCertDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cert.pem"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", filepath.Join(dir, "cert.pem")) + return dir +} + +func readCalls(t *testing.T, logFile string) string { + t.Helper() + b, _ := os.ReadFile(logFile) + return string(b) +} + +func TestEnsureCloudflareTunnel_happyPath_logsInWhenNoCert(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TUNNEL_ORIGIN_CERT", "") + if err := os.MkdirAll(filepath.Join(home, ".cloudflared"), 0755); err != nil { + t.Fatal(err) + } + logFile := fakeCloudflared(t, "created", 0, "routed", 0) + + name, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "lerd-mysite" { + t.Errorf("name = %q, want lerd-mysite", name) + } + calls := readCalls(t, logFile) + for _, want := range []string{"tunnel login", "tunnel create lerd-mysite", "tunnel route dns lerd-mysite dev.example.com"} { + if !strings.Contains(calls, want) { + t.Errorf("calls %q missing %q", calls, want) + } + } +} + +func TestEnsureCloudflareTunnel_skipsLoginWhenCertExists(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + logFile := fakeCloudflared(t, "created", 0, "routed", 0) + + if _, err := ensureCloudflareTunnel("mysite", "dev.example.com"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(readCalls(t, logFile), "tunnel login") { + t.Error("tunnel login was called despite existing cert") + } +} + +func TestEnsureCloudflareTunnel_toleratesExistingTunnelAndRoute(t *testing.T) { + certWithTunnelCredentials(t) + fakeCloudflared(t, "tunnel with name already exists", 1, "record with that host already exists", 1) + + var name string + var err error + out := captureStdout(t, func() { name, err = ensureCloudflareTunnel("mysite", "dev.example.com") }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "lerd-mysite" { + t.Errorf("name = %q, want lerd-mysite", name) + } + if !strings.Contains(out, "already exists") { + t.Errorf("expected a note about the existing record, got %q", out) + } +} + +func TestEnsureCloudflareTunnel_existingTunnelMissingCredentials(t *testing.T) { + certWithoutTunnelCredentials(t) + fakeCloudflared(t, "tunnel with name already exists", 1, "routed", 0) + + _, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), "credentials file is missing") { + t.Errorf("error = %v, want it to name the missing credentials file", err) + } + if !strings.Contains(err.Error(), fakeTunnelID+".json") { + t.Errorf("error = %v, want it to name the expected credentials path", err) + } +} + +func TestEnsureCloudflareTunnel_freshRouteWarnsAboutPropagation(t *testing.T) { + certWithTunnelCredentials(t) + fakeCloudflared(t, "created", 0, "Added CNAME dev.example.com which will route to this tunnel", 0) + + out := captureStdout(t, func() { + if _, err := ensureCloudflareTunnel("mysite", "dev.example.com"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if !strings.Contains(out, "30 minutes") { + t.Errorf("a freshly added record should warn about resolver caching, got %q", out) + } +} + +func TestEnsureCloudflareTunnel_reusedRouteStaysQuiet(t *testing.T) { + certWithTunnelCredentials(t) + fakeCloudflared(t, "created", 0, "", 0) + + out := captureStdout(t, func() { + if _, err := ensureCloudflareTunnel("mysite", "dev.example.com"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if strings.Contains(out, "30 minutes") { + t.Errorf("an unchanged route should not warn about propagation, got %q", out) + } +} + +func TestEnsureCloudflareTunnel_createFailurePropagates(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + fakeCloudflared(t, "auth error", 1, "routed", 0) + + _, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err == nil || !strings.Contains(err.Error(), "tunnel create") { + t.Errorf("error = %v, want tunnel create failure", err) + } +} + +func TestEnsureCloudflareTunnel_routeFailurePropagates(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + fakeCloudflared(t, "created", 0, "zone not found", 1) + + _, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err == nil || !strings.Contains(err.Error(), "route dns") { + t.Errorf("error = %v, want route dns failure", err) + } +} + // ── startHostProxy ──────────────────────────────────────────────────────────── // proxyPort parses the port from a URL string like "http://127.0.0.1:PORT". diff --git a/internal/cli/share_tool.go b/internal/cli/share_tool.go new file mode 100644 index 00000000..24484994 --- /dev/null +++ b/internal/cli/share_tool.go @@ -0,0 +1,104 @@ +package cli + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/geodro/lerd/internal/config" + "github.com/geodro/lerd/internal/feedback" + "github.com/spf13/cobra" +) + +// shareTools lists the settable default tunnel tools in the order they are +// offered, with the binary each one needs in PATH. SSH-based tools need no +// dedicated binary beyond ssh itself. +var shareTools = []struct{ name, binary string }{ + {"ngrok", "ngrok"}, + {"cloudflare", "cloudflared"}, + {"expose", "expose"}, + {"serveo", "ssh"}, + {"localhost-run", "ssh"}, +} + +func shareToolNames() []string { + names := make([]string, 0, len(shareTools)) + for _, t := range shareTools { + names = append(names, t.name) + } + return names +} + +func shareToolBinary(name string) (string, bool) { + for _, t := range shareTools { + if t.name == name { + return t.binary, true + } + } + return "", false +} + +// NewShareToolCmd returns the share:tool command. +func NewShareToolCmd() *cobra.Command { + return &cobra.Command{ + Use: "share:tool [ngrok|cloudflare|expose|serveo|localhost-run|auto]", + Short: "Show or set the default tunnel tool for lerd share", + Long: `Without an argument, prints the current default tunnel tool. + +With an argument, sets the tool "lerd share" uses when no tool flag is passed. +"auto" clears the default and restores auto-detection +(ngrok, then cloudflared, then Expose, then localhost.run). + +The tool must already be installed. A tool flag on "lerd share" still wins for +that run, and "lerd share --domain" always uses Cloudflare Tunnel.`, + Example: ` lerd share:tool + lerd share:tool cloudflare + lerd share:tool auto`, + Args: cobra.MaximumNArgs(1), + RunE: runShareTool, + } +} + +func runShareTool(_ *cobra.Command, args []string) error { + cfg, err := config.LoadGlobal() + if err != nil { + return err + } + + if len(args) == 0 { + if cfg.Share.DefaultTool == "" { + fmt.Println("auto (detects ngrok, then cloudflared, then Expose, then localhost.run)") + } else { + fmt.Println(cfg.Share.DefaultTool) + } + fmt.Printf("\nChange it with: lerd share:tool %s|auto\n", strings.Join(shareToolNames(), "|")) + return nil + } + + tool := strings.ToLower(args[0]) + if tool == "auto" { + cfg.Share.DefaultTool = "" + if err := config.SaveGlobal(cfg); err != nil { + return err + } + feedback.Begin() + feedback.Done("share tool reset to " + feedback.Val("auto-detect")) + return nil + } + + bin, ok := shareToolBinary(tool) + if !ok { + return fmt.Errorf("unknown tool %q: use %s, or auto", tool, strings.Join(shareToolNames(), ", ")) + } + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("%s requires %q which is not in PATH, install it first", tool, bin) + } + + cfg.Share.DefaultTool = tool + if err := config.SaveGlobal(cfg); err != nil { + return err + } + feedback.Begin() + feedback.Done("default share tool set to " + feedback.Val(tool)) + return nil +} diff --git a/internal/cli/share_tool_test.go b/internal/cli/share_tool_test.go new file mode 100644 index 00000000..b9b4cf96 --- /dev/null +++ b/internal/cli/share_tool_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/geodro/lerd/internal/config" +) + +func TestShareToolNames_matchBinaries(t *testing.T) { + names := shareToolNames() + if len(names) != len(shareTools) { + t.Fatalf("shareToolNames() returned %d names for %d tools", len(names), len(shareTools)) + } + for _, name := range names { + bin, ok := shareToolBinary(name) + if !ok { + t.Errorf("%q is offered but has no binary mapping", name) + } + if bin == "" { + t.Errorf("%q maps to an empty binary", name) + } + } + if _, ok := shareToolBinary("auto"); ok { + t.Error(`"auto" must not resolve to a binary, it clears the default`) + } + if _, ok := shareToolBinary("bogus"); ok { + t.Error("an unknown tool must not resolve to a binary") + } +} + +// The order is what the command line and the "change it with" hint offer, and it +// mirrors the auto-detection order in pickShareTool. +func TestShareToolNames_order(t *testing.T) { + want := "ngrok|cloudflare|expose|serveo|localhost-run" + if got := strings.Join(shareToolNames(), "|"); got != want { + t.Errorf("shareToolNames() = %q, want %q", got, want) + } +} + +func TestNewShareToolCmd_helpMentionsEveryTool(t *testing.T) { + cmd := NewShareToolCmd() + help := cmd.Use + "\n" + cmd.Long + "\n" + cmd.Example + for _, name := range shareToolNames() { + if !strings.Contains(help, name) { + t.Errorf("help does not mention %q:\n%s", name, help) + } + } + for _, want := range []string{"auto", "lerd share:tool cloudflare"} { + if !strings.Contains(help, want) { + t.Errorf("help does not mention %q:\n%s", want, help) + } + } +} + +func TestRunShareTool_rejectsUnknownTool(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + err := runShareTool(nil, []string{"bogus"}) + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), `unknown tool "bogus"`) { + t.Errorf("error = %v, want it to name the rejected tool", err) + } + for _, name := range shareToolNames() { + if !strings.Contains(err.Error(), name) { + t.Errorf("error should list %q as a valid choice, got %v", name, err) + } + } +} + +func TestRunShareTool_rejectsToolWithoutItsBinary(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + err := runShareTool(nil, []string{"ngrok"}) + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), "not in PATH") { + t.Errorf("error = %v, want it to say the binary is missing", err) + } +} + +func TestRunShareTool_setShowReset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("PATH", fakeBin(t, "ngrok")) + + if err := runShareTool(nil, []string{"ngrok"}); err != nil { + t.Fatalf("setting ngrok: %v", err) + } + cfg, err := config.LoadGlobal() + if err != nil { + t.Fatalf("loading config: %v", err) + } + if cfg.Share.DefaultTool != "ngrok" { + t.Errorf("DefaultTool = %q, want ngrok", cfg.Share.DefaultTool) + } + + // Showing the default also has to say how to change it, otherwise the command + // reports a value with no way to act on it. + out := captureStdout(t, func() { + if err := runShareTool(nil, nil); err != nil { + t.Errorf("showing: %v", err) + } + }) + if !strings.Contains(out, "ngrok") { + t.Errorf("show output %q does not report the current tool", out) + } + if !strings.Contains(out, "lerd share:tool") { + t.Errorf("show output %q does not say how to change it", out) + } + + if err := runShareTool(nil, []string{"auto"}); err != nil { + t.Fatalf("resetting: %v", err) + } + cfg, err = config.LoadGlobal() + if err != nil { + t.Fatalf("reloading config: %v", err) + } + if cfg.Share.DefaultTool != "" { + t.Errorf("DefaultTool = %q after auto, want it cleared", cfg.Share.DefaultTool) + } +} diff --git a/internal/config/global.go b/internal/config/global.go index 01041f90..b7c0d5e6 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -102,6 +102,12 @@ type GlobalConfig struct { // would otherwise re-add the shims a `node:unmanage` removed. Managed *bool `yaml:"managed,omitempty" mapstructure:"managed"` } `yaml:"node" mapstructure:"node"` + Share struct { + // DefaultTool is the tunnel tool "lerd share" uses when no flag is + // given: ngrok | cloudflare | expose | serveo | localhost-run. + // Empty = auto-detect. Set via "lerd share:tool". + DefaultTool string `yaml:"default_tool,omitempty" mapstructure:"default_tool"` + } `yaml:"share,omitempty" mapstructure:"share"` Nginx struct { HTTPPort int `yaml:"http_port" mapstructure:"http_port"` HTTPSPort int `yaml:"https_port" mapstructure:"https_port"`