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
1 change: 1 addition & 0 deletions cmd/lerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hostname>` | 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` |
Expand Down
22 changes: 21 additions & 1 deletion docs/usage/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 <hostname>` | 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-<site>`, 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.

Expand Down
164 changes: 153 additions & 11 deletions internal/cli/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"compress/gzip"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
Expand All @@ -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]",
Expand All @@ -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)
},
}

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

Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading