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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/lerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,13 @@ func main() {
root.AddCommand(cli.NewLANStatusCmd())
root.AddCommand(cli.NewLANShareCmd())
root.AddCommand(cli.NewLANUnshareCmd())
root.AddCommand(cli.NewLANServicesCmd())
root.AddCommand(cli.NewRemoteSetupCmd())
root.AddCommand(cli.NewRemoteControlCmd())
root.AddCommand(cli.NewRemoteControlOnCmd())
root.AddCommand(cli.NewRemoteControlOffCmd())
root.AddCommand(cli.NewRemoteControlStatusCmd())
root.AddCommand(cli.NewRemoteControlFullAccessCmd())
root.AddCommand(newWatchCmd())
root.AddCommand(newServeUICmd())

Expand Down
2 changes: 1 addition & 1 deletion docs/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,4 @@ Shell completion populates command names: `lerd run <TAB>` lists what's availabl

Two commands cannot run on the same site at the same time; the API returns `409 Conflict` if a second run is attempted while one is in flight. This protects against accidentally running `migrate:fresh` twice from two tabs.

The run endpoint is loopback-only, LAN clients (when the access mode allows remote viewing) can see the list of commands but cannot execute them. The list endpoint is read-only and exposed everywhere lerd-ui is reachable.
The run endpoint is available to the local dashboard and to authenticated remote dashboard sessions. The same per-site concurrency guard applies to both.
2 changes: 1 addition & 1 deletion docs/features/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ The same capture is available to an AI assistant through lerd's MCP server, so a

## Open in editor

Every query's caller path in the Queries lens is a link. Expand a row to see the originating application frame (`Class::method — file:line`) and a **Details** button for the full stack trace; click any `file:line` to open it in your editor. lerd autodetects a known GUI editor (VS Code, Cursor, PhpStorm, Sublime, Zed, …); override it with an `editor` command in `~/.config/lerd/config.yaml`, e.g. `editor: "phpstorm --line {line} {file}"` ({file} and {line} are substituted). The endpoint is loopback-only.
Every query's caller path in the Queries lens is a link. Expand a row to see the originating application frame (`Class::method — file:line`) and a **Details** button for the full stack trace; click any `file:line` to open it in the host's editor. lerd autodetects a known GUI editor (VS Code, Cursor, PhpStorm, Sublime, Zed, …); override it with an `editor` command in `~/.config/lerd/config.yaml`, e.g. `editor: "phpstorm --line {line} {file}"` ({file} and {line} are substituted). The endpoint requires dashboard-control authority, which authenticated remote sessions receive.

## Caveats

Expand Down
1 change: 1 addition & 0 deletions docs/features/system-tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ PHP 8.5 ▸ ✔ 8.5 ← current default

Settings ▸ Autostart at login: ✔ On ← enables/disables every lerd unit
Expose to LAN: Off ← Linux only
Managed service LAN access: Off ← explicit database/cache port access
Debug bridge: Off ← `lerd dump on/off`
Notifications: ✔ On ← `lerd notify on/off`
High-contrast icon: Off ← `lerd tray icon default/high-contrast`
Expand Down
20 changes: 10 additions & 10 deletions docs/features/web-ui.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,24 @@ The proxy runs inside the lerd daemon (`lerd-ui`), no external tool needed and n

`lerd share` (without `lan:`) is different: it wraps an external tunnel tool (ngrok/cloudflared/Expose/SSH) to expose the site to the **public internet**.

### Full LAN exposure (all sites, DNS-based)
### Full LAN exposure (DNS-based)

| Command | Description |
|---|---|
| `lerd lan:expose` | Expose all lerd services to the LAN: binds nginx to `0.0.0.0`, starts the DNS forwarder |
| `lerd lan:unexpose` | Restrict everything back to `127.0.0.1` |
| `lerd lan:status` | Show whether lerd is currently exposed to the local network |
| `lerd lan:expose` | Expose sites, DNS, and the dashboard listener to the LAN |
| `lerd lan:unexpose` | Restrict all Lerd endpoints to loopback |
| `lerd lan:status` | Show site and managed-service LAN exposure state |
| `lerd lan:services on` | Explicitly include managed databases, caches, and services |
| `lerd lan:services off` | Return managed services to loopback without hiding sites |
| `lerd lan:services status` | Show the persisted managed-service setting |
| `lerd remote-control full-access on` | Let authenticated remote sessions run host actions |
| `lerd remote-control full-access off` | Keep host actions local-only (the default) |
| `lerd remote-control full-access status` | Show the persisted host-action setting |

The dashboard **System** tab and terminal UI expose the same independent
settings. Host actions such as reading a site's `.env`, browsing the
filesystem, dropping databases or opening a terminal stay local-only until
`lerd remote-control full-access on`, which only the lerd host can set.

See [Remote / LAN Development](/usage/remote-development) for the full walkthrough.

Expand Down
67 changes: 63 additions & 4 deletions docs/usage/remote-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,38 @@ lerd lan:expose

This single command:

- Rewrites the `lerd-nginx` quadlet so its `PublishPort=` bindings drop the `127.0.0.1:` prefix (port 80 / 443 become reachable from other devices on the LAN). **Service containers stay on `127.0.0.1` in both modes**; Laravel apps reach them through the internal podman bridge using container DNS names (`DB_HOST=lerd-mysql`, etc.), so there's no reason to expose mysql/postgres/redis/meilisearch/rustfs/mailpit ports to the network. If you need TablePlus or another tool from a second machine, use SSH port forwarding instead.
- Rewrites `lerd-nginx` so ports 80 and 443 become reachable from other
devices. Managed databases, caches, and mail services remain loopback-only
unless you explicitly enable their LAN access.
- Restarts `lerd-nginx` so the new bind takes effect.
- Updates the dnsmasq config so `.test` queries return the server's auto-detected LAN IP instead of `127.0.0.1`, and starts the userspace `lerd-dns-forwarder.service` that bridges `LAN-IP:5300` to `127.0.0.1:5300` (rootless pasta cannot accept LAN-side traffic on its own).
- Persists `lan.exposed: true` in `~/.config/lerd/config.yaml` so reboots and reinstalls restore the exposed state.

Reverse with `lerd lan:unexpose` (also revokes any outstanding remote-setup code). Inspect the current state with `lerd lan:status`.

You can do the same thing from the dashboard: in **Lerd settings > LAN exposure**, click **Expose to LAN** and watch the per-step progress stream live.
#### Optional: expose managed services

On a trusted development network, you can allow remote database clients and
other tools to connect directly to Lerd-managed services:

```bash
lerd lan:services on
```

This setting is off by default and persists independently of `lan:expose`.
When both settings are on, Lerd publishes every installed managed service on
its configured host port. New services inherit the setting automatically.
Port changes are reapplied, stopped services have no endpoint, and inactive
services remain stopped. Use `lerd lan:services status` to inspect the setting
or `lerd lan:services off` to return all managed services to loopback.

This option exposes databases and caches without adding authentication. Limit
their ports with the host firewall and use it only on a trusted network.

The same controls are available on the dashboard **System** tab. Use **LAN
exposure** for sites and DNS, and **Managed service LAN access** for databases,
caches, mail, and custom services. The terminal UI exposes both settings in its
Settings and System views.

The dashboard at port 7073 is gated independently. By default it returns 403 to LAN clients even when `lan:expose` is on; set HTTP Basic auth credentials with `lerd remote-control on` (or via the **Remote dashboard access** card in the dashboard) to grant LAN access. The two switches are independent: you can have sites LAN-reachable without exposing the dashboard, or vice versa.

Expand Down Expand Up @@ -306,7 +330,23 @@ lerd remote-control on # 2. set the Basic auth credentials
# Remote dashboard access enabled.
```

The password is bcrypt-hashed (default cost) and stored in `~/.config/lerd/config.yaml`. From this point on, loopback bypasses everything; LAN requests must present HTTP Basic auth. Re-running `lerd remote-control on` rotates the password.
The password is bcrypt-hashed (default cost) and stored in `~/.config/lerd/config.yaml`. From this point on, loopback bypasses everything; LAN requests must present HTTP Basic auth. Actions run on the host that runs Lerd. Re-running `lerd remote-control on` rotates the password.

### Host actions stay local by default

An authenticated remote session drives the dashboard, but the actions that reach the host itself are held back: reading a site's raw `.env` (app key, database credentials, tokens), browsing the filesystem, linking arbitrary paths as sites, dropping or exporting databases, opening a terminal, replacing tooling on the host's PATH, and shutting lerd down. A remote client asking for one of those gets 403 even with valid credentials, and the dashboard hides the controls that map to them.

Opt in when you want the full thing from another device:

```bash
lerd remote-control full-access on # allow host actions remotely
lerd remote-control full-access status # on, off, or enabled-but-inert
lerd remote-control full-access off # back to local-only
```

The same switch lives in the dashboard's **Remote dashboard access** card, as **Host actions from remote sessions**. Either way it can only be changed from the machine running lerd, so a remote session can never widen its own authority. `lerd remote-control off` clears it along with the credentials.

Turning it on means the dashboard password is the only thing between the LAN and your files, secrets and shell, so treat it the way you would an SSH key: trusted networks only, and rotate the password with `lerd remote-control on` if it has ever been shared.

Disable either flag at any time:

Expand All @@ -325,7 +365,26 @@ Once the dashboard is exposed and credentials are set, the **Remote dashboard ac

## Security caveats

- **Coffee shop wifi: leave `lan:expose` off.** That's the default and it binds nginx to `127.0.0.1` only, so sites are invisible to other devices on the network. Service containers (mysql, postgres, redis, mailpit, etc.) are *always* loopback-only regardless of `lan:expose`, so even with the LAN flag on, your dev databases are not network-reachable. Only run `lerd lan:expose` on networks you trust.
- **Coffee shop wifi: leave `lan:expose` off.** That is the default, and it
keeps sites and managed services invisible to other devices. Managed service
ports remain loopback-only during normal LAN exposure unless you explicitly
run `lerd lan:services on`. Only enable either setting on a trusted network.
- **Managed service LAN access can publish unauthenticated databases and caches.**
MySQL, Redis, and similar development services may use weak or empty
credentials. Restrict their ports with the host firewall before running
`lerd lan:services on`.
- **A reverse proxy in front of the dashboard does not inherit local trust.**
Tailscale Serve, Caddy or nginx relaying a remote browser connect from
127.0.0.1, which would otherwise look local. lerd treats a loopback request
carrying `X-Forwarded-For`, `X-Forwarded-Host`, `X-Real-IP` or `Forwarded` as
remote, so it still faces the LAN gate and Basic auth. A browser on the
machine itself is unaffected, including one reaching lerd by the machine's
own hostname. A proxy configured to strip those headers would appear local,
so terminate it in front of the auth you want, not behind it.
- **`lerd remote-control full-access on` puts your host behind one password.**
Host actions are local-only by default for this reason. With the opt-in on,
anyone who guesses or obtains the dashboard password can read every site's
`.env`, browse the filesystem and run commands on the machine.
- **`lerd lan:expose` makes your dnsmasq an open recursive resolver for anyone on the LAN.** Lock down with firewall rules to your subnet, not 0.0.0.0/0.
- **The mkcert root CA has authority over any HTTPS site on the trusting machine.** Only install the CA on devices you own. Treat the private key (which never leaves the server) as a high-value secret.
- **The `/api/remote-setup` endpoint hands out the public CA to anyone who can pass the source-IP and code checks.** Don't share active codes.
Expand Down
125 changes: 53 additions & 72 deletions internal/cli/dns.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"errors"
"fmt"
"net"
"os"
Expand All @@ -16,29 +17,6 @@ import (
"github.com/geodro/lerd/internal/services"
)

// lanExposureContainers is the canonical list of lerd containers whose
// PublishPort= bindings change between loopback and LAN modes.
//
// Only lerd-nginx is included on purpose: serving the sites is the whole
// point of lan:expose. The service containers (mysql, postgres, redis,
// meilisearch, rustfs, mailpit, etc.) intentionally stay bound to
// 127.0.0.1 in both modes — Laravel apps in lerd-php-fpm reach them via
// the podman bridge using container DNS names (DB_HOST=lerd-mysql, etc.),
// which is unaffected by the host bind. Exposing the database ports to
// the LAN by default would only matter for the rare "TablePlus from a
// second machine" use case, and would be a significant attack surface
// expansion on untrusted wifi. Power users who genuinely need that can
// SSH-tunnel or hand-edit a single quadlet.
//
// lerd-dns is also intentionally excluded: its publish is already pinned
// to 127.0.0.1:5300 in the embed (LAN access goes through the userspace
// lerd-dns-forwarder, not a publish flip), so regenerating its quadlet
// would be a no-op. EnableLANExposure restarts the lerd-dns unit
// separately to pick up the new dnsmasq target config.
var lanExposureContainers = []string{
"lerd-nginx",
}

// LANProgressFunc is invoked by EnableLANExposure / DisableLANExposure
// after every meaningful step completes. The argument is a short
// human-readable label suitable for streaming to a frontend ("Rewriting
Expand All @@ -47,18 +25,14 @@ var lanExposureContainers = []string{
// streaming, internal idempotent re-application from `lerd remote-setup`).
type LANProgressFunc func(step string)

// EnableLANExposure flips lerd from the safe-on-coffee-shop-wifi default
// (everything bound to 127.0.0.1) to LAN-exposed mode. Concretely:
// EnableLANExposure flips lerd sites from the safe loopback default to
// LAN-exposed mode. Concretely:
//
// - persists cfg.LAN.Exposed=true so reinstalls and reboots restore the state
// - regenerates every installed lerd-* container quadlet via WriteQuadlet,
// which centrally rewrites PublishPort= lines to drop the loopback prefix
// - daemon-reloads systemd and restarts each rewritten container
// - rewrites the dnsmasq config to answer *.test queries with the host's
// LAN IP and restarts lerd-dns
// - installs and starts the userspace lerd-dns-forwarder.service that
// bridges LAN-IP:5300 → 127.0.0.1:5300 (rootless pasta cannot accept
// LAN-side traffic on its own, so a host-side forwarder is required)
// - persists cfg.LAN.Exposed=true
// - exposes nginx and, when cfg.LAN.ServicesExposed is set, managed services
// - daemon-reloads the runtime and restarts only rewritten active containers
// - rewrites dnsmasq to answer *.test with the host's LAN IP
// - installs the userspace DNS forwarder where the platform requires it
//
// progress, if non-nil, is invoked after each step so the caller can
// stream feedback to a user (e.g. NDJSON over HTTP for the dashboard).
Expand All @@ -80,11 +54,9 @@ func EnableLANExposure(progress LANProgressFunc) (lanIP string, err error) {
return "", fmt.Errorf("saving config: %w", err)
}

if cfg.DNS.Enabled {
emit("Rewriting container quadlets")
if err := regenerateLANContainerQuadlets(progress); err != nil {
return "", err
}
emit("Rewriting container quadlets")
if err := regenerateLANContainerQuadlets(progress); err != nil {
return "", err
}

emit("Detecting primary LAN IP")
Expand Down Expand Up @@ -198,11 +170,9 @@ func DisableLANExposure(progress LANProgressFunc) error {
return fmt.Errorf("revoking remote-setup token: %w", err)
}

if cfg.DNS.Enabled {
emit("Rewriting container quadlets")
if err := regenerateLANContainerQuadlets(progress); err != nil {
return err
}
emit("Rewriting container quadlets")
if err := regenerateLANContainerQuadlets(progress); err != nil {
return err
}

if cfg.DNS.Enabled {
Expand All @@ -225,46 +195,57 @@ func DisableLANExposure(progress LANProgressFunc) error {
return nil
}

// regenerateLANContainerQuadlets re-reads each installed lerd-* container
// quadlet from the embed FS, runs it back through WriteQuadlet (which now
// applies BindForLAN based on cfg.LAN.Exposed), then daemon-reloads and
// restarts the running containers so the new PublishPort bindings take
// effect. Containers that aren't installed are skipped. progress, if
// non-nil, receives a per-container "Restarting <name>" event so callers
// streaming feedback can show finer-grained progress.
func regenerateLANContainerQuadlets(progress LANProgressFunc) error {
restarted := []string{}
for _, name := range lanExposureContainers {
if !podman.QuadletInstalled(name) {
continue
}
content, err := podman.GetQuadletTemplate(name + ".container")
if err != nil {
return fmt.Errorf("reading %s quadlet template: %w", name, err)
}
if err := podman.WriteContainerUnitFn(name, content); err != nil {
return fmt.Errorf("rewriting %s quadlet: %w", name, err)
}
restarted = append(restarted, name)
// SetManagedServiceLANExposure persists the explicit managed-service opt-in
// and reapplies the bind policy to every installed quadlet. Active services
// restart when their host bind changes; inactive services remain stopped.
func SetManagedServiceLANExposure(enabled bool, progress LANProgressFunc) error {
cfg, err := config.LoadGlobal()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
cfg.LAN.ServicesExposed = enabled
if err := config.SaveGlobal(cfg); err != nil {
return fmt.Errorf("saving config: %w", err)
}
return regenerateLANContainerQuadlets(progress)
}

if len(restarted) == 0 {
// regenerateLANContainerQuadlets reapplies the current LAN bind policy to every
// installed lerd container while preserving each unit's current configuration.
// Only affected units that are already running are restarted; inactive runtime
// services remain inactive.
//
// Every unit is attempted even when one fails. Stopping at the first error
// would leave the units after it still bound to their old address while the
// config, the CLI and the dashboard all report the new one, and because the
// files on disk are already correct by then, re-running would find nothing to
// do and the drift would never clear.
func regenerateLANContainerQuadlets(progress LANProgressFunc) error {
restart, err := podman.RebindInstalledQuadletsForLAN()
if err != nil {
return err
}
if len(restart) == 0 {
return nil
}

if err := services.Mgr.DaemonReload(); err != nil {
return fmt.Errorf("daemon-reload: %w", err)
}
for _, name := range restarted {
var failures []error
for _, name := range restart {
status, _ := services.Mgr.UnitStatus(name)
if status != "active" && status != "activating" {
continue
}
if progress != nil {
progress("Restarting " + name)
}
// Ignore individual container restart errors so a single dead
// service doesn't block the rest of the toggle. The user will
// see the bad state via `lerd doctor` / podman ps.
_ = services.Mgr.Restart(name)
if err := services.Mgr.Restart(name); err != nil {
failures = append(failures, fmt.Errorf("restarting %s: %w", name, err))
}
}
return nil
return errors.Join(failures...)
}

// Seams for preflightForwarderPort so the logic can be unit-tested
Expand Down
Loading
Loading