From 051104d78de43c444d22d277aabc1ed1538d0bf9 Mon Sep 17 00:00:00 2001
From: charlie
Date: Thu, 30 Jul 2026 16:24:40 -0600
Subject: [PATCH 1/3] feat: add opt-in LAN access for managed services
---
cmd/lerd/main.go | 1 +
docs/features/commands.md | 2 +-
docs/features/queries.md | 2 +-
docs/features/system-tray.md | 1 +
docs/features/web-ui.md | 20 +-
docs/reference/commands.md | 14 +-
docs/usage/remote-development.md | 39 ++-
internal/cli/dns.go | 115 +++----
internal/cli/install.go | 14 +-
internal/cli/lan.go | 130 +++++---
internal/cli/lan_services_test.go | 54 ++++
internal/cli/status.go | 8 +
internal/cli/status_test.go | 21 +-
internal/config/global.go | 25 +-
internal/podman/lan_rebind_test.go | 136 +++++++++
internal/podman/quadlet.go | 60 +++-
internal/podman/quadlet_embed.go | 9 +
internal/services/launchd_darwin.go | 6 +-
internal/tray/list_test.go | 19 ++
internal/tray/menu.go | 13 +
internal/tray/tray.go | 13 +
internal/tui/settings.go | 26 +-
internal/tui/settings_test.go | 29 ++
internal/tui/system.go | 15 +-
internal/ui/app_logs_clear.go | 5 +-
internal/ui/cleanup.go | 5 +-
internal/ui/commands.go | 6 +-
internal/ui/commands_test.go | 2 +-
internal/ui/dashproxy.go | 2 +-
internal/ui/devtools.go | 2 +-
internal/ui/dumps.go | 11 +-
internal/ui/editor.go | 7 +-
internal/ui/lan_status_test.go | 129 ++++++++
internal/ui/logterminal.go | 3 +-
internal/ui/notify_target_http.go | 4 +-
internal/ui/openfolder.go | 4 +-
internal/ui/profiler.go | 4 +-
internal/ui/remote_control.go | 286 +++++++++---------
internal/ui/remote_control_test.go | 176 ++++++-----
internal/ui/server.go | 14 +-
internal/ui/site_doctor.go | 4 +-
internal/ui/web/demo/fixtures/lan_status.json | 2 +-
internal/ui/web/demo/stubs.ts | 20 ++
internal/ui/web/messages/de.json | 10 +-
internal/ui/web/messages/en.json | 10 +-
internal/ui/web/messages/es.json | 10 +-
internal/ui/web/messages/fr.json | 10 +-
internal/ui/web/messages/id.json | 10 +-
internal/ui/web/messages/it.json | 10 +-
internal/ui/web/messages/ja.json | 10 +-
internal/ui/web/messages/nl.json | 10 +-
internal/ui/web/messages/pl.json | 10 +-
internal/ui/web/messages/pt.json | 10 +-
internal/ui/web/messages/ro.json | 10 +-
internal/ui/web/messages/tr.json | 10 +-
internal/ui/web/messages/vi.json | 10 +-
internal/ui/web/messages/zh.json | 10 +-
.../web/src/components/CommandPalette.svelte | 4 +-
.../ui/web/src/components/MobileNav.svelte | 6 +-
internal/ui/web/src/components/NavRail.svelte | 6 +-
internal/ui/web/src/components/Toggle.svelte | 1 +
internal/ui/web/src/lib/editor.ts | 7 +-
internal/ui/web/src/stores/accessMode.test.ts | 16 +-
internal/ui/web/src/stores/accessMode.ts | 11 +-
internal/ui/web/src/stores/lan.test.ts | 87 ++++++
internal/ui/web/src/stores/lan.ts | 152 +++++++---
internal/ui/web/src/tabs/ServicesTab.svelte | 2 +-
internal/ui/web/src/tabs/SitesTab.svelte | 6 +-
internal/ui/web/src/tabs/SitesTab.test.ts | 6 +-
internal/ui/web/src/tabs/SystemTab.svelte | 4 +-
.../web/src/tabs/dashboard/HeroStatus.svelte | 2 +-
.../src/tabs/dashboard/LerdInfoWidget.svelte | 2 +-
.../src/tabs/dashboard/OnboardingPanel.svelte | 4 +-
.../src/tabs/dashboard/ServicesWidget.svelte | 2 +-
.../web/src/tabs/dashboard/SitesWidget.svelte | 2 +-
.../tabs/dashboard/SystemHealthWidget.svelte | 27 +-
.../src/tabs/services/ServiceDetail.svelte | 12 +-
.../src/tabs/services/ServiceDetail.test.ts | 21 +-
.../src/tabs/services/ServiceHeader.svelte | 12 +-
.../tabs/services/ServicesDashboard.svelte | 2 +-
.../ui/web/src/tabs/sites/SiteHeader.svelte | 19 +-
.../web/src/tabs/sites/SitesDashboard.svelte | 2 +-
.../ui/web/src/tabs/system/LerdDetail.svelte | 128 ++++++--
83 files changed, 1532 insertions(+), 609 deletions(-)
create mode 100644 internal/cli/lan_services_test.go
create mode 100644 internal/podman/lan_rebind_test.go
create mode 100644 internal/ui/lan_status_test.go
create mode 100644 internal/ui/web/src/stores/lan.test.ts
diff --git a/cmd/lerd/main.go b/cmd/lerd/main.go
index 18b0754df..f483b379a 100644
--- a/cmd/lerd/main.go
+++ b/cmd/lerd/main.go
@@ -240,6 +240,7 @@ 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())
diff --git a/docs/features/commands.md b/docs/features/commands.md
index 0e28c93ca..b60db1c53 100644
--- a/docs/features/commands.md
+++ b/docs/features/commands.md
@@ -123,4 +123,4 @@ Shell completion populates command names: `lerd run ` 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.
\ No newline at end of file
+The run endpoint is available to the local dashboard and to authenticated remote dashboard sessions. The same per-site concurrency guard applies to both.
\ No newline at end of file
diff --git a/docs/features/queries.md b/docs/features/queries.md
index 874f7b519..c1e8c70bb 100644
--- a/docs/features/queries.md
+++ b/docs/features/queries.md
@@ -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
diff --git a/docs/features/system-tray.md b/docs/features/system-tray.md
index 09df757d6..d3c642772 100644
--- a/docs/features/system-tray.md
+++ b/docs/features/system-tray.md
@@ -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`
diff --git a/docs/features/web-ui.md b/docs/features/web-ui.md
index cf5083032..38277910d 100644
--- a/docs/features/web-ui.md
+++ b/docs/features/web-ui.md
@@ -52,11 +52,11 @@ The dashboard ships in fourteen languages: English, German, Spanish, French, Ind
The Dashboard is the root page (`#dashboard`) and the default destination when the UI loads. It hides the middle list panel and fills the main pane with a responsive grid of widgets:
-- **Sites**: total / running / paused / failing counts, the top frameworks across linked sites as red badges, and a **Link site** call to action (loopback only) that opens the same modal as the Sites tab `+` button.
-- **Services**: an active-vs-total summary pill, a click-through banner when one or more services have updates available, a two-column list of every core service with status dot and version, an **Add** button (loopback only) that opens the preset picker, and a link into the Services tab.
+- **Sites**: total / running / paused / failing counts, the top frameworks across linked sites as red badges, and a **Link site** call to action that opens the same modal as the Sites tab `+` button.
+- **Services**: an active-vs-total summary pill, a click-through banner when one or more services have updates available, a two-column list of every core service with status dot and version, an **Add** button that opens the preset picker, and a link into the Services tab.
- **Workers**: per-group counts (Queues, Schedules, Horizon, Reverb, Stripe, custom Workers), a red pulsing dot when any unit in a group is failing, and a **Heal all** button that runs the same heal flow as the worker-health banner. Otherwise shows an "All healthy" pill.
- **System health**: overall pill (Healthy / Attention / Problem) derived from DNS, Nginx, and the file watcher, plus a row per component and a chip per installed PHP-FPM version coloured by its running state.
-- **Lerd**: current version, "Up to date" or a yellow "update available" banner with an **Open terminal & update** button (loopback only), Autostart and LAN status pills, plus **Check for updates** and **Manage →** in the footer.
+- **Lerd**: current version, "Up to date" or a yellow "update available" banner with an **Open terminal & update** button, Autostart and LAN status pills, plus **Check for updates** and **Manage →** in the footer.
- **Resources**: total CPU%, total memory, and reclaimable disk across lerd's whole footprint, the `lerd-*` containers plus lerd's own host-side processes (the UI, watcher, and tray daemons and any host worker such as a Vite dev server), with the memory bar also showing its share of host RAM, and a ranked list of the heaviest contributors by combined CPU and memory. The disk figure is what [`lerd cleanup`](/usage/cleanup) would reclaim from orphaned images and build cache; a **Clean up** button runs it from a modal that previews what goes, so you can see the space before deciding to take it.
Every widget is driven by the same Svelte stores that power the rest of the dashboard, so all values stay live over the WebSocket without polling.
@@ -70,7 +70,7 @@ Press **`Cmd+K`** (macOS) / **`Ctrl+K`** (Linux/Windows), or **`/`** anywhere ou
- **Pages**: Dashboard, Sites, Services, System
- **Sites**: every linked domain, with framework hint
- **Services**: every core service, with version hint
-- **Install service** (loopback only), every installable bundled preset, so searching "install redis" installs it inline without opening the picker modal
+- **Install service**: every installable bundled preset, so searching "install redis" installs it inline without opening the picker modal
- **Actions**: Link a site, Add a service, Heal failing workers (when any), Check for updates, Open documentation, Open current site in browser, Toggle theme
Use `↑` / `↓` to move the selection, `↵` to execute, `esc` to close. The palette is available on every tab, not just the dashboard.
@@ -91,7 +91,7 @@ Before you pick a site the detail panel shows a **sites overview** instead of an
Selecting a site opens the detail panel with:
-- **Address bar header**: a browser-style row with the site's favicon, scheme, and domain. The leading **lock icon** toggles TLS in one click (green closed when enabled, gray open when disabled, static on worktrees and when DNS is off). Clicking the domain opens the Manage Domains modal, and the **sliders** button at the end of the bar edits the site's nginx override. To the right sits an action toolbar, every button the same size: **open in browser**, **group**, a **share** button, **xdebug**, **terminal** (loopback only), and a **⋮** menu holding restart, pin, pause/resume, and unlink. On narrow panels the secondary actions fold into the ⋮ menu. The project **path** shows at the right of the tab row (Overview, Logs, Env and so on), centred against the tabs and shortened to `~/` when it sits under your home directory; clicking it opens the folder in your file manager (loopback only). A site with no tabs of its own, a paused one for instance, shows the path in a row beneath the address bar instead. The framework badge and a paused indicator sit in the header's right cluster. Hovering any icon button reveals a themed label tooltip. When LAN sharing is on, the shareable URL appears as a teal chip with a hover-QR.
+- **Address bar header**: a browser-style row with the site's favicon, scheme, and domain. The leading **lock icon** toggles TLS in one click (green closed when enabled, gray open when disabled, static on worktrees and when DNS is off). Clicking the domain opens the Manage Domains modal, and the **sliders** button at the end of the bar edits the site's nginx override. To the right sits an action toolbar, every button the same size: **open in browser**, **group**, a **share** button, **xdebug**, **terminal**, and a **⋮** menu holding restart, pin, pause/resume, and unlink. On narrow panels the secondary actions fold into the ⋮ menu. The project **path** shows at the right of the tab row (Overview, Logs, Env and so on), centred against the tabs. Host actions such as **terminal** run on the machine that runs Lerd.
- **Share menu**: clicking the wifi button toggles LAN sharing exactly as before, while hovering (or keyboard-focusing) it opens a menu with both share modes. The **Local network** section mirrors the LAN toggle with the share URL inline. The **Public tunnel** section starts a [`lerd share`](../usage/sites.md#sharing-sites) tunnel without leaving the dashboard: an auto entry picks the same tool a bare `lerd share` would, each supported tool (ngrok, Cloudflare Tunnel, Expose, Serveo, localhost.run) is listed beneath it, and tools missing from the machine show up disabled with an install hint. Starting a tunnel waits for the tool to print its public URL, which then appears as a violet chip next to the domain with the same hover-QR as the LAN link, and the menu offers a **Stop** action. Tunnels started from the UI are owned by `lerd-ui`: they end when stopped or when the daemon shuts down, and unlike LAN shares they are never resurrected on restart. Tunnels front the site's primary domain, so the section hides while a worktree tab is active, and on narrow panels the tunnel start/stop actions live in the ⋮ menu.
- **Overview layout**: the Overview tab is a stack of sections rather than one long strip, a **Runtime & workers** row with the PHP/Node pickers, worker toggles, a **Doctor** button, and the **Commands ▾** dropdown, and a **Services** grid of icon cards. Live logs moved out to their own **Logs** tab.
- **PHP / Node dropdowns**: change the version per site; writes `.php-version` / `.node-version` into the project and regenerates the nginx vhost on the fly
@@ -114,7 +114,7 @@ Selecting a site opens the detail panel with:
- **Remove Worktree modal**: opens scoped to a single branch when its tab's × is clicked. Offers a *Discard uncommitted changes* (force) checkbox and, when isolated, an *Also drop database* checkbox. Runs `lerd worktree remove` and closes once the branch is gone
- **Live PHP-FPM log**: streams FPM output for the selected site; tab switches to queue/horizon/schedule/reverb logs when those workers are running
- **Coloured output**: every live log pane renders the ANSI colours the tool emitted, so Vite, Pest, artisan and composer read the same as they do in a terminal. Workers and UI-run commands are started with `FORCE_COLOR`, `CLICOLOR_FORCE` and a colour-capable `TERM` because they write to a pipe or a log file rather than a terminal and would otherwise strip their own colours; setting `NO_COLOR` in the environment lerd starts from turns all of that back off
-- **Follow in terminal**: the terminal icon in a log pane's header opens your terminal emulator tailing the same unit (`podman logs -f`, `tail -f`, or `journalctl -f` depending on the platform and the unit), so a long tail can outlive the browser tab. Loopback only, and it uses `$TERMINAL` when set
+- **Follow in terminal**: the terminal icon in a log pane's header opens the host's terminal emulator tailing the same unit (`podman logs -f`, `tail -f`, or `journalctl -f` depending on the platform and the unit), so a long tail can outlive the browser tab. It uses `$TERMINAL` when set. Authenticated remote dashboards show the same action; the terminal opens on the host that runs Lerd.

@@ -136,11 +136,11 @@ The header has a **+** button that opens the **preset picker modal**: a one-clic

-Before you pick a service the detail panel shows a **services dashboard** instead of an empty prompt. The header carries an Overview line with the running-vs-total count, an updates-available indicator, and the number of sites currently served. Below it an **Installed** grid lists every core service as a click-through card carrying the same category-tinted service icon the presets below it use, with its running/stopped status, version, pending-update arrow, and linked-site count. A running card that ships a dashboard also carries an open-dashboard button, and one with no dashboard of its own falls back to its paired admin UI when that is installed, so the mysql card opens phpMyAdmin and the postgres card opens pgAdmin, starting the admin service first if it is stopped. A **Discover services** section (loopback only) then promotes the bundled presets you have not installed yet, grouped by category (Databases, Cache, Messaging, Search, Mail & PDF, Admin UIs, Storage, Testing). Each preset is a card showing its service icon, name, and a one-line description, with an **Add** button that installs it inline with live phase feedback and jumps straight to the new service when it comes up. A preset only shows here while you run none of it, so an existing mysql or mariadb install is never promoted again just to offer its other versions; adding an alternate version stays in the preset-picker modal, which the section's **+** shortcut still opens.
+Before you pick a service the detail panel shows a **services dashboard** instead of an empty prompt. The header carries an Overview line with the running-vs-total count, an updates-available indicator, and the number of sites currently served. Below it an **Installed** grid lists every core service as a click-through card carrying the same category-tinted service icon the presets below it use, with its running/stopped status, version, pending-update arrow, and linked-site count. A running card that ships a dashboard also carries an open-dashboard button, and one with no dashboard of its own falls back to its paired admin UI when that is installed, so the mysql card opens phpMyAdmin and the postgres card opens pgAdmin, starting the admin service first if it is stopped. A **Discover services** section then promotes the bundled presets you have not installed yet, grouped by category (Databases, Cache, Messaging, Search, Mail & PDF, Admin UIs, Storage, Testing). Each preset is a card showing its service icon, name, and a one-line description, with an **Add** button that installs it inline with live phase feedback and jumps straight to the new service when it comes up. A preset only shows here while you run none of it, so an existing mysql or mariadb install is never promoted again just to offer its other versions; adding an alternate version stays in the preset-picker modal, which the section's **+** shortcut still opens.
Selecting a service opens the detail panel with Start, Stop, and Restart controls, status, and the correct `.env` connection values with a one-click copy button. Restart is available for every built-in and custom service and wraps `podman restart` (clears the paused flag on success); the grouped per-site workers (Queues, Horizon, Schedules, Workers, Stripe, Reverb) remain start/stop only. A **Check for updates** action sits in the service's action menu (non-worker services only); it bypasses the cached availability lookup, re-fetches the registry tag list, and shows either an "Already up to date" hint or a "Update available: {tag}" banner that becomes the live Update button moments later. Database service detail panels (mysql, postgres, mongo, and any installed alternate like `mysql-5-7`) get a few extras:
-- **Databases tab**: the panel opens on it, listing what is actually inside the running engine as cards with their sizes. From a card you can create and drop a database, export it to a plain SQL dump or import one, copy a per-database connection string, and open it in an installed admin tool, with that database's snapshots to take, restore, delete or download on the same card. Cards link back to the site that owns the database, a `_testing` database folds into the card of the database it tests, and a worktree's isolated database is shown under its own branch domain. The tab is loopback only and is not offered on a LAN-exposed dashboard. See [Databases](../usage/database.md).
+- **Databases tab**: the panel opens on it, listing what is actually inside the running engine as cards with their sizes. From a card you can create and drop a database, export it to a plain SQL dump or import one, copy a per-database connection string, and open it in an installed admin tool, with that database's snapshots to take, restore, delete or download on the same card. Cards link back to the site that owns the database, a `_testing` database folds into the card of the database it tests, and a worktree's isolated database is shown under its own branch domain. It requires dashboard-control authority, which authenticated remote sessions receive. See [Databases](../usage/database.md).
- **Suggestion banner**: a sky-blue tip offering to install the paired admin UI (phpMyAdmin / pgAdmin / Mongo Express) when it isn't installed yet. Dismissable per-preset; dismissal persists in `localStorage`.
- **Open admin button**: when the paired admin UI is installed, a button on the header opens its dashboard inline as a full-width iframe overlay and auto-starts the admin service if needed. When no admin UI is installed and the service is active, a fallback **Open connection URL** anchor hands the `mysql://` / `postgresql://` / `mongodb://` URL to your registered DB client (DBeaver, TablePlus, Compass, etc.).
- **Dashboard button**: for any service that exposes a dashboard URL (Mailpit, RustFS, Meilisearch, phpMyAdmin, etc.), a Dashboard button in the header opens it as an inline full-width iframe. The iframe overlay has its own header with the service URL, an **Open in new tab** escape hatch, and a close button. Clicking one of the main nav icons (Sites / Services / System) also closes the overlay.
@@ -164,10 +164,10 @@ Selecting an item opens its detail panel:
- **Watcher card**: shows whether `lerd-watcher` is running; a Start button appears when stopped. Streams live watcher logs (DNS repair events, fsnotify errors, worktree timeouts).
- **Notifications card**: per-category toggles (mail captured, worker failures, finished service operations, service updates, possible N+1 queries, dumps), a *Send a test notification* button, and the list of subscribed browsers with *Forget* actions. See [Notifications](./notifications.md).
- **Autostart card**: enable or disable automatic start of all services at login.
-- **Lerd card**: shows the current version and a **Check for updates** button. Clicking it spins the button and queries GitHub live, bypassing the 24-hour cache, so the result reflects the newest release right now rather than a stale cached answer. The status dot next to the entry is green when DNS, nginx, and the watcher are all running, red when any of them is down, and yellow when an update is available. When an update is available, an **Open terminal & update** button spawns the user's preferred terminal emulator with `lerd update` pre-filled (loopback only, the host needs to prompt for sudo). A small yellow dot also appears on the lerd logo in the left rail; clicking the logo always returns to the Dashboard, where the same update banner is surfaced on the Lerd widget.
+- **Lerd card**: shows the current version and a **Check for updates** button. Clicking it spins the button and queries GitHub live, bypassing the 24-hour cache, so the result reflects the newest release right now rather than a stale cached answer. The status dot next to the entry is green when DNS, nginx, and the watcher are all running, red when any of them is down, and yellow when an update is available. When an update is available, an **Open terminal & update** button spawns the host's preferred terminal emulator with `lerd update` pre-filled. A small yellow dot also appears on the lerd logo in the left rail; clicking the logo always returns to the Dashboard, where the same update banner is surfaced on the Dashboard tab.
The **Start** / **Stop** buttons in the System panel header start or stop all core services (DNS, nginx, and all PHP-FPM containers for versions that have active sites).
## Updates
-Shows the current version. When an update is available, the Lerd entry exposes an **Open terminal & update** button that launches your terminal emulator running `lerd update`. The update requires `sudo` for sysctl/sudoers steps and so needs an interactive terminal; the button is loopback-only and is hidden when the dashboard is reached over the LAN.
+Shows the current version. When an update is available, the Lerd entry exposes an **Open terminal & update** button that launches the host's terminal emulator running `lerd update`. An authenticated remote dashboard shows the same action; the terminal opens on the host that runs Lerd.
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index 1ebb9310b..df1c78be4 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -109,13 +109,19 @@ 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 |
+
+The dashboard **System** tab and terminal UI expose the same two independent
+settings. Authenticated remote dashboard sessions receive the same controls.
See [Remote / LAN Development](/usage/remote-development) for the full walkthrough.
diff --git a/docs/usage/remote-development.md b/docs/usage/remote-development.md
index fa6eed671..344685731 100644
--- a/docs/usage/remote-development.md
+++ b/docs/usage/remote-development.md
@@ -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.
@@ -306,7 +330,7 @@ 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. An authenticated remote session receives the same dashboard and controls as a local session. Actions run on the host that runs Lerd. Re-running `lerd remote-control on` rotates the password.
Disable either flag at any time:
@@ -325,7 +349,14 @@ 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`.
- **`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.
diff --git a/internal/cli/dns.go b/internal/cli/dns.go
index 68a4cf801..56963319e 100644
--- a/internal/cli/dns.go
+++ b/internal/cli/dns.go
@@ -16,29 +16,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
@@ -47,18 +24,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).
@@ -80,11 +53,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")
@@ -198,11 +169,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 {
@@ -225,44 +194,48 @@ 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 " 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 changed units that are already running are restarted; inactive runtime
+// services remain inactive.
+func regenerateLANContainerQuadlets(progress LANProgressFunc) error {
+ changed, err := podman.RebindInstalledQuadletsForLAN()
+ if err != nil {
+ return err
+ }
+ if len(changed) == 0 {
return nil
}
if err := services.Mgr.DaemonReload(); err != nil {
return fmt.Errorf("daemon-reload: %w", err)
}
- for _, name := range restarted {
+ for _, name := range changed {
+ 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 {
+ return fmt.Errorf("restarting %s: %w", name, err)
+ }
}
return nil
}
diff --git a/internal/cli/install.go b/internal/cli/install.go
index 42cc882e5..2c176cf1c 100644
--- a/internal/cli/install.go
+++ b/internal/cli/install.go
@@ -581,15 +581,11 @@ func runInstall(cmd *cobra.Command, _ []string) error {
}
ok()
- // Note: WriteQuadlet centrally applies podman.BindForLAN based on
- // cfg.LAN.Exposed, so containers default to binding 127.0.0.1 unless
- // the user has run `lerd lan:expose on`. We use WriteQuadletDiff
- // (which reports whether the on-disk file actually changed) so we
- // can restart only the units whose binds shifted — important during
- // the upgrade from a pre-LAN-toggle release where nginx was bound to
- // 0.0.0.0 by default. Without the restart the running container
- // would silently keep its old LAN-exposed bind even though the
- // quadlet on disk now says 127.0.0.1.
+ // WriteQuadlet centrally applies the unit-aware LAN policy. Nginx follows
+ // cfg.LAN.Exposed; managed services also require cfg.LAN.ServicesExposed.
+ // WriteQuadletDiff lets this install restart only units whose binds changed.
+ // This also repairs drift from older releases without starting inactive
+ // services.
changedQuadlets := []string{}
extraVolumes := podman.ExtraVolumePaths()
// rewriteEmbedded handles the remaining embedded-template quadlets:
diff --git a/internal/cli/lan.go b/internal/cli/lan.go
index b040b3080..302990689 100644
--- a/internal/cli/lan.go
+++ b/internal/cli/lan.go
@@ -10,33 +10,26 @@ import (
"github.com/spf13/cobra"
)
-// NewLANCmd returns the `lerd lan` parent command. Subcommands flip lerd
-// between the safe-on-coffee-shop-wifi default (everything bound to
-// 127.0.0.1) and the LAN-exposed state (containers bound to 0.0.0.0,
-// dnsmasq answering with the LAN IP, lerd-ui on 0.0.0.0:7073). The
-// previous standalone `lerd dns:expose` flag was folded in here because
-// there is no meaningful state where the DNS resolver answers the LAN
-// but the actual services don't.
+// NewLANCmd returns the `lerd lan` parent command. Site exposure and managed
+// service exposure are separate persisted settings: sites follow
+// cfg.LAN.Exposed, while databases, caches, and other managed services require
+// both cfg.LAN.Exposed and cfg.LAN.ServicesExposed.
func NewLANCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "lan",
Short: "Expose lerd to other devices on the local network",
- Long: `Toggle whether lerd's services are reachable from other devices on
-the local network.
+ Long: `Control whether lerd sites and managed services are reachable from
+other devices on the local network.
-By default lerd binds every container PublishPort to 127.0.0.1 and the
-dashboard (lerd-ui) listens only on 127.0.0.1:7073. Other devices on the
-LAN cannot reach the sites, services, mail UI, or dashboard. This is the
-safe default for untrusted networks (cafés, conference wifi, hotel
-networks).
-
-Run 'lerd lan:expose on' to flip everything to 0.0.0.0 binds and start
-the userspace DNS forwarder so LAN devices can resolve and reach your
-sites. Run 'lerd lan:expose off' to revert.`,
+By default every container PublishPort and the dashboard bind to loopback.
+Run 'lerd lan:expose' to expose sites, DNS, and the dashboard listener on a
+trusted LAN. Managed databases, caches, and other services remain loopback-only
+unless you explicitly run 'lerd lan:services on'.`,
}
cmd.AddCommand(newLANExposeCmd())
cmd.AddCommand(newLANUnexposeCmd())
cmd.AddCommand(newLANStatusCmd())
+ cmd.AddCommand(newLANServicesCmd())
cmd.AddCommand(newLANShareCmd())
cmd.AddCommand(newLANUnshareCmd())
return cmd
@@ -80,34 +73,36 @@ func NewLANUnshareCmd() *cobra.Command {
return cmd
}
+// NewLANServicesCmd returns the `lerd lan:services` colon-style command.
+func NewLANServicesCmd() *cobra.Command {
+ cmd := newLANServicesCmd()
+ cmd.Use = "lan:services [on|off|status]"
+ return cmd
+}
+
func newLANExposeCmd() *cobra.Command {
return &cobra.Command{
Use: "expose",
Short: "Make lerd reachable from other devices on the local network",
- Long: `Flips lerd from its safe loopback default to LAN-exposed mode:
+ Long: `Exposes lerd sites on a trusted local network:
+
+ - Rewrites lerd-nginx so ports 80 and 443 bind to the LAN.
+ - Restarts nginx when its bind changes.
+ - Rewrites dnsmasq to answer *.test with the host's LAN IP.
+ - Starts the userspace DNS forwarder where the platform requires it.
- - Rewrites every installed lerd-* container quadlet so PublishPort=
- bindings drop the 127.0.0.1 prefix (sites, services, mail UI, etc.
- become reachable from other devices on the LAN).
- - Restarts each affected container so the new bind takes effect.
- - Rewrites the dnsmasq config to answer *.test queries with the host's
- auto-detected LAN IP so LAN devices can resolve those names. On Linux
- this is bridged by the userspace lerd-dns-forwarder; on macOS lerd-dns
- binds the LAN address directly, so no forwarder is installed.
+Managed databases, caches, and other services stay loopback-only by default.
+Run 'lerd lan:services on' once to include them. That preference persists and
+applies automatically as services start, stop, or change ports.
-The dashboard at port 7073 is still gated by the remote-control middleware:
-LAN clients get 403 unless you have run 'lerd remote-control on' to set
-HTTP Basic auth credentials. The two switches are independent — sites
-become LAN-reachable on lan:expose, the dashboard becomes LAN-reachable
-on remote-control on, and you can have either or both.
+The dashboard at port 7073 is gated by remote-control middleware. LAN clients
+get 403 unless 'lerd remote-control on' has configured HTTP Basic auth.
-The state is persisted in ~/.config/lerd/config.yaml so reboots and
-reinstalls restore the exposed state. Idempotent — re-running heals any
-state drift between the config flag and the actual on-disk units.
+The state persists in ~/.config/lerd/config.yaml. Re-running this command heals
+drift between the config and installed runtime units.
-Make sure your firewall allows the relevant ports (typically 80, 443,
-5300, 7073) from the devices you want to grant access. 'lerd remote-setup'
-generates a one-shot bootstrap code for a remote machine.`,
+Only use LAN exposure on a trusted network. Configure the host firewall for the
+ports and devices that require access.`,
RunE: func(_ *cobra.Command, _ []string) error {
cfg, _ := config.LoadGlobal()
dnsOn := cfg == nil || cfg.DNS.Enabled
@@ -142,6 +137,11 @@ generates a one-shot bootstrap code for a remote machine.`,
} else {
feedback.Note(fmt.Sprintf("dashboard: http://%s:7073 (LAN clients get 403 — run `lerd remote-control on` to grant LAN access)", lanIP))
}
+ if cfg != nil && cfg.LAN.ServicesExposed {
+ feedback.Note("managed services: exposed on their configured host ports")
+ } else {
+ feedback.Note("managed services: loopback-only (run `lerd lan:services on` to expose them)")
+ }
if dnsOn {
feedback.Note("allow ports 80, 443, 5300, 7073 through your firewall; `lerd remote-setup` generates a one-time bootstrap code")
} else {
@@ -281,6 +281,52 @@ func notifyDaemon(domain, action string) error {
return nil
}
+func newLANServicesCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "services [on|off|status]",
+ Short: "Control LAN access to managed databases, caches, and services",
+ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
+ ValidArgs: []string{"on", "off", "status"},
+ RunE: func(_ *cobra.Command, args []string) error {
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ return err
+ }
+
+ if args[0] == "status" {
+ feedback.Begin()
+ switch {
+ case cfg.LAN.ServicesExposed && cfg.LAN.Exposed:
+ feedback.Done("managed service LAN access is active")
+ case cfg.LAN.ServicesExposed:
+ feedback.Line("managed service LAN access is enabled but inactive until `lerd lan:expose`")
+ default:
+ feedback.Line("managed service LAN access is off; services are loopback-only")
+ }
+ return nil
+ }
+
+ enabled := args[0] == "on"
+ feedback.Begin()
+ update := feedback.Start("updating managed service LAN access")
+ if err := SetManagedServiceLANExposure(enabled, nil); err != nil {
+ update.Fail(err)
+ return err
+ }
+ if enabled {
+ update.OK(feedback.Val("enabled"))
+ feedback.Note("development services may use weak or empty credentials; restrict their ports with the host firewall and use only on a trusted network")
+ if !cfg.LAN.Exposed {
+ feedback.Note("services remain loopback-only until `lerd lan:expose`")
+ }
+ } else {
+ update.OK(feedback.Val("loopback-only"))
+ }
+ return nil
+ },
+ }
+}
+
func newLANStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
@@ -290,15 +336,19 @@ func newLANStatusCmd() *cobra.Command {
if err != nil {
return err
}
+ feedback.Begin()
if cfg.LAN.Exposed {
lanIP, _ := detectPrimaryLANIP()
if lanIP == "" {
lanIP = "(unknown)"
}
- feedback.Begin()
feedback.Done("exposed to the LAN at " + feedback.Val(lanIP))
+ if cfg.LAN.ServicesExposed {
+ feedback.Note("managed services: exposed")
+ } else {
+ feedback.Note("managed services: loopback-only")
+ }
} else {
- feedback.Begin()
feedback.Line("loopback-only (127.0.0.1) — LAN devices cannot reach it")
}
return nil
diff --git a/internal/cli/lan_services_test.go b/internal/cli/lan_services_test.go
new file mode 100644
index 000000000..e936ed7a9
--- /dev/null
+++ b/internal/cli/lan_services_test.go
@@ -0,0 +1,54 @@
+package cli
+
+import (
+ "testing"
+
+ "github.com/geodro/lerd/internal/config"
+)
+
+func TestLANServicesCommandPersistsExplicitOptIn(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ cmd := newLANServicesCmd()
+ cmd.SetArgs([]string{"on"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("services on: %v", err)
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal after on: %v", err)
+ }
+ if !cfg.LAN.ServicesExposed {
+ t.Fatal("services on did not persist lan.services_exposed")
+ }
+
+ cmd = newLANServicesCmd()
+ cmd.SetArgs([]string{"off"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("services off: %v", err)
+ }
+ cfg, err = config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal after off: %v", err)
+ }
+ if cfg.LAN.ServicesExposed {
+ t.Fatal("services off did not clear lan.services_exposed")
+ }
+}
+
+func TestLANServicesCommandRejectsUnknownState(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ cmd := newLANServicesCmd()
+ cmd.SetArgs([]string{"maybe"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("services command accepted an unknown state")
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if cfg.LAN.ServicesExposed {
+ t.Fatal("invalid state changed lan.services_exposed")
+ }
+}
diff --git a/internal/cli/status.go b/internal/cli/status.go
index deb1ea9b5..3dfb20535 100644
--- a/internal/cli/status.go
+++ b/internal/cli/status.go
@@ -379,6 +379,14 @@ func printRemoteAccessStatus(cfg *config.GlobalConfig, lanIP string) {
} else {
warn2("LAN exposure", "loopback only — enable with: lerd lan expose")
}
+ switch {
+ case cfg.LAN.ServicesExposed && cfg.LAN.Exposed:
+ ok2("Managed service LAN access")
+ case cfg.LAN.ServicesExposed:
+ warn2("Managed service LAN access", "enabled but inactive until LAN exposure is on")
+ default:
+ ok2("Managed service LAN access (off; services loopback-only)")
+ }
if cfg.UI.PasswordHash != "" {
ok2(fmt.Sprintf("Dashboard remote access (user: %s)", cfg.UI.Username))
} else {
diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go
index 4759354e1..725dcfdcc 100644
--- a/internal/cli/status_test.go
+++ b/internal/cli/status_test.go
@@ -41,6 +41,7 @@ func TestPrintRemoteAccessStatus(t *testing.T) {
exposed bool
lanIP string
username string
+ services bool
passHash string
wantSubstr []string
}{
@@ -50,6 +51,7 @@ func TestPrintRemoteAccessStatus(t *testing.T) {
wantSubstr: []string{
"LAN exposure",
"loopback only",
+ "Managed service LAN access (off; services loopback-only)",
"lerd lan expose",
"Dashboard remote access",
"LAN clients get 403",
@@ -57,12 +59,22 @@ func TestPrintRemoteAccessStatus(t *testing.T) {
},
},
{
- name: "lan exposed, dashboard off",
- exposed: true,
- lanIP: "192.168.1.42",
+ name: "managed services enabled while LAN is off",
+ services: true,
+ wantSubstr: []string{
+ "Managed service LAN access",
+ "enabled but inactive until LAN exposure is on",
+ },
+ },
+ {
+ name: "lan exposed, dashboard off",
+ exposed: true,
+ services: true,
+ lanIP: "192.168.1.42",
wantSubstr: []string{
"LAN exposure (192.168.1.42)",
"✓",
+ "Managed service LAN access",
"Dashboard remote access",
"LAN clients get 403",
},
@@ -71,11 +83,13 @@ func TestPrintRemoteAccessStatus(t *testing.T) {
name: "both on",
exposed: true,
lanIP: "10.0.0.5",
+ services: true,
username: "george",
passHash: "$2a$10$fakehashfakehashfakehashfakehashfakehashfakehashfake",
wantSubstr: []string{
"LAN exposure (10.0.0.5)",
"Dashboard remote access (user: george)",
+ "Managed service LAN access",
},
},
{
@@ -92,6 +106,7 @@ func TestPrintRemoteAccessStatus(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.GlobalConfig{}
cfg.LAN.Exposed = tc.exposed
+ cfg.LAN.ServicesExposed = tc.services
cfg.UI.Username = tc.username
cfg.UI.PasswordHash = tc.passHash
diff --git a/internal/config/global.go b/internal/config/global.go
index 65fce3086..992effbfc 100644
--- a/internal/config/global.go
+++ b/internal/config/global.go
@@ -155,21 +155,18 @@ type GlobalConfig struct {
Upstream []string `yaml:"upstream,omitempty" mapstructure:"upstream"`
} `yaml:"dns" mapstructure:"dns"`
LAN struct {
- // Exposed controls whether lerd's services are reachable from
- // other devices on the local network. When false (the default,
- // safe-on-coffee-shop-wifi state) every container PublishPort is
- // rewritten to bind 127.0.0.1, lerd-ui binds 127.0.0.1:7073, and
- // the lerd-dns-forwarder is stopped. When true, container ports
- // bind 0.0.0.0, lerd-ui binds 0.0.0.0:7073, dnsmasq is rewritten
- // to answer .test queries with the host's LAN IP, and the
- // userspace lerd-dns-forwarder runs to bridge LAN-IP:5300 to the
- // loopback-only DNS container.
+ // Exposed controls whether lerd sites are reachable from other devices
+ // on the local network. When false (the safe default), container ports
+ // and lerd-ui bind to loopback and the DNS forwarder is stopped. When
+ // true, nginx, DNS, and the dashboard bind to the LAN.
//
- // Toggled via `lerd lan:expose on/off`. The previous standalone
- // `dns:expose` flag was folded in here because there is no
- // meaningful state where the DNS resolver answers the LAN but
- // the actual services don't.
- Exposed bool `yaml:"exposed,omitempty" mapstructure:"exposed"`
+ // ServicesExposed separately controls host access to lerd-managed
+ // databases, caches, and other services. It has no effect unless
+ // Exposed is also true. Keeping this opt-in separate preserves the safe
+ // default while allowing trusted development machines to publish
+ // services without per-port configuration.
+ Exposed bool `yaml:"exposed,omitempty" mapstructure:"exposed"`
+ ServicesExposed bool `yaml:"services_exposed,omitempty" mapstructure:"services_exposed"`
} `yaml:"lan,omitempty" mapstructure:"lan"`
Autostart struct {
// Disabled controls whether lerd boots itself at login. The
diff --git a/internal/podman/lan_rebind_test.go b/internal/podman/lan_rebind_test.go
new file mode 100644
index 000000000..5ee528b41
--- /dev/null
+++ b/internal/podman/lan_rebind_test.go
@@ -0,0 +1,136 @@
+package podman
+
+import (
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/geodro/lerd/internal/config"
+)
+
+func TestRebindInstalledQuadletsForLANKeepsServicesPrivateByDefault(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, true, false)
+ writeLANQuadlet(t, "lerd-nginx", false, "PublishPort=127.0.0.1:443:443\nPublishPort=[::1]:443:443")
+ writeLANQuadlet(t, "lerd-redis", true, "PublishPort=127.0.0.1:6379:6379\nPublishPort=[::1]:6379:6379")
+
+ changed, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if !slices.Equal(changed, []string{"lerd-nginx"}) {
+ t.Fatalf("changed units = %v, want [lerd-nginx]", changed)
+ }
+ if content := readLANQuadlet(t, "lerd-nginx"); strings.Contains(content, "127.0.0.1:") || strings.Contains(content, "[::1]:") {
+ t.Fatalf("nginx remains loopback-bound:\n%s", content)
+ }
+ if content := readLANQuadlet(t, "lerd-redis"); !strings.Contains(content, "PublishPort=127.0.0.1:6379:6379") {
+ t.Fatalf("redis did not remain loopback-bound:\n%s", content)
+ }
+}
+
+func TestRebindInstalledQuadletsForLANExposesOnlyOptedInServices(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, true, true)
+ writeLANQuadlet(t, "lerd-nginx", false, "PublishPort=127.0.0.1:443:443\nPublishPort=[::1]:443:443")
+ writeLANQuadlet(t, "lerd-mysql", true, "PublishPort=127.0.0.1:3306:3306\nPublishPort=[::1]:3306:3306")
+ writeLANQuadlet(t, "lerd-custom-search", true, "PublishPort=127.0.0.1:7700:7700\nPublishPort=[::1]:7700:7700")
+ writeLANQuadlet(t, "lerd-site-worker", false, "PublishPort=127.0.0.1:9000:9000\nPublishPort=[::1]:9000:9000")
+ writeLANQuadlet(t, "lerd-dns", false, "PublishPort=127.0.0.1:5300:5300\nPublishPort=[::1]:5300:5300")
+
+ changed, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ for _, name := range []string{"lerd-nginx", "lerd-mysql", "lerd-custom-search"} {
+ if !slices.Contains(changed, name) {
+ t.Errorf("changed units %v do not include %s", changed, name)
+ }
+ content := readLANQuadlet(t, name)
+ if strings.Contains(content, "127.0.0.1:") || strings.Contains(content, "[::1]:") {
+ t.Errorf("%s remains loopback-bound:\n%s", name, content)
+ }
+ }
+ for _, name := range []string{"lerd-site-worker", "lerd-dns"} {
+ if slices.Contains(changed, name) {
+ t.Errorf("%s must remain loopback-bound, changed units: %v", name, changed)
+ }
+ }
+}
+
+func TestRebindInstalledQuadletsForLANRestoresLoopbackAndIsIdempotent(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, false, true)
+ writeLANQuadlet(t, "lerd-redis", true, "PublishPort=[::]:6379:6379")
+
+ changed, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if !slices.Equal(changed, []string{"lerd-redis"}) {
+ t.Fatalf("changed units = %v, want [lerd-redis]", changed)
+ }
+ content := readLANQuadlet(t, "lerd-redis")
+ if !strings.Contains(content, "PublishPort=127.0.0.1:6379:6379") || !strings.Contains(content, "PublishPort=[::1]:6379:6379") {
+ t.Fatalf("redis was not restored to dual-stack loopback:\n%s", content)
+ }
+
+ changed, err = RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("second RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if len(changed) != 0 {
+ t.Fatalf("idempotent rebind changed %v", changed)
+ }
+}
+
+func TestWriteQuadletDiffAppliesServiceExposureToNewServices(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, true, true)
+ content := CustomServiceQuadletMarker + "\n[Container]\nImage=docker.io/library/redis:7.4.9-alpine\nNetwork=lerd\nPublishPort=127.0.0.1:6379:6379\n"
+
+ if _, err := WriteQuadletDiff("lerd-redis", content); err != nil {
+ t.Fatalf("WriteQuadletDiff: %v", err)
+ }
+ written := readLANQuadlet(t, "lerd-redis")
+ if strings.Contains(written, "127.0.0.1:") || strings.Contains(written, "[::1]:") {
+ t.Fatalf("new service did not inherit opted-in LAN exposure:\n%s", written)
+ }
+}
+
+func writeLANConfig(t *testing.T, exposed, servicesExposed bool) {
+ t.Helper()
+ cfg := &config.GlobalConfig{}
+ cfg.LAN.Exposed = exposed
+ cfg.LAN.ServicesExposed = servicesExposed
+ if err := config.SaveGlobal(cfg); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+}
+
+func writeLANQuadlet(t *testing.T, name string, managedService bool, ports string) {
+ t.Helper()
+ dir := config.QuadletDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir quadlet dir: %v", err)
+ }
+ marker := ""
+ if managedService {
+ marker = CustomServiceQuadletMarker + "\n"
+ }
+ content := marker + "[Container]\nImage=docker.io/library/redis:7.4.9-alpine\nNetwork=lerd\n" + ports + "\n\n[Service]\nRestart=always\n\n[Install]\nWantedBy=default.target\n"
+ if err := os.WriteFile(filepath.Join(dir, name+".container"), []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+}
+
+func readLANQuadlet(t *testing.T, name string) string {
+ t.Helper()
+ content, err := os.ReadFile(filepath.Join(config.QuadletDir(), name+".container"))
+ if err != nil {
+ t.Fatalf("read %s: %v", name, err)
+ }
+ return string(content)
+}
diff --git a/internal/podman/quadlet.go b/internal/podman/quadlet.go
index acfe274a5..65770d1d9 100644
--- a/internal/podman/quadlet.go
+++ b/internal/podman/quadlet.go
@@ -41,10 +41,9 @@ func DaemonReloadIfNeeded(changed bool) error {
}
// WriteQuadlet writes a Podman quadlet container unit file. Before writing
-// it applies BindForLAN to rewrite PublishPort= lines according to the
-// current cfg.LAN.Exposed setting. This is done centrally here so callers
-// (install, services, MCP server, custom-service generator) all get the
-// same loopback-by-default treatment without each having to remember.
+// it applies the current LAN bind policy centrally. Nginx follows
+// cfg.LAN.Exposed. Lerd-managed services require both cfg.LAN.Exposed and
+// cfg.LAN.ServicesExposed. Other containers stay loopback-bound.
func WriteQuadlet(name, content string) error {
_, err := WriteQuadletDiff(name, content)
return err
@@ -62,12 +61,14 @@ func WriteQuadletDiff(name, content string) (changed bool, err error) {
return false, err
}
lanExposed := false
+ servicesExposed := false
autostartDisabled := false
if cfg, err := config.LoadGlobal(); err == nil && cfg != nil {
lanExposed = cfg.LAN.Exposed
+ servicesExposed = cfg.LAN.ServicesExposed
autostartDisabled = cfg.Autostart.Disabled
}
- content = BindForLAN(content, lanExposed)
+ content = BindQuadletForLAN(name, content, lanExposed, servicesExposed)
content = PairIPv6Binds(content)
content = StripInstallSection(content, autostartDisabled)
// Centralised platform image rewrite + podman-run flags so every quadlet
@@ -114,6 +115,55 @@ func QuadletInstalled(name string) bool {
return err == nil
}
+// BindQuadletForLAN applies the LAN policy for one quadlet. Nginx serves sites,
+// while CustomServiceQuadletMarker identifies default and custom managed
+// services. Site and worker containers do not publish directly to the LAN.
+func BindQuadletForLAN(name, content string, lanExposed, servicesExposed bool) string {
+ exposed := lanExposed && (name == "lerd-nginx" ||
+ (servicesExposed && strings.Contains(content, CustomServiceQuadletMarker)))
+ return BindForLAN(content, exposed)
+}
+
+// RebindInstalledQuadletsForLAN reapplies the current LAN policy to every
+// installed lerd container. It rewrites only changed units and preserves each
+// installed unit's image, ports, volumes, and custom settings.
+func RebindInstalledQuadletsForLAN() ([]string, error) {
+ lanExposed := false
+ servicesExposed := false
+ if cfg, err := config.LoadGlobal(); err == nil && cfg != nil {
+ lanExposed = cfg.LAN.Exposed
+ servicesExposed = cfg.LAN.ServicesExposed
+ }
+ paths, err := filepath.Glob(filepath.Join(config.QuadletDir(), "lerd-*.container"))
+ if err != nil {
+ return nil, err
+ }
+
+ changed := make([]string, 0, len(paths))
+ for _, path := range paths {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading %s: %w", filepath.Base(path), err)
+ }
+ name := strings.TrimSuffix(filepath.Base(path), ".container")
+ updated := PairIPv6Binds(BindQuadletForLAN(name, string(content), lanExposed, servicesExposed))
+ if string(content) == updated {
+ continue
+ }
+ config.GuardRealWrite(path)
+ if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
+ return nil, fmt.Errorf("rewriting %s: %w", filepath.Base(path), err)
+ }
+ if AfterQuadletWriteFn != nil {
+ if err := AfterQuadletWriteFn(name, updated); err != nil {
+ return nil, fmt.Errorf("syncing %s: %w", name, err)
+ }
+ }
+ changed = append(changed, name)
+ }
+ return changed, nil
+}
+
// ListManagedServiceNames returns the service names (lerd- prefix and .container
// suffix stripped) of every quadlet carrying CustomServiceQuadletMarker. Used by
// ReconcileServices to find orphans without misclassifying site/worker quadlets.
diff --git a/internal/podman/quadlet_embed.go b/internal/podman/quadlet_embed.go
index 5a60d3ef2..26b711bf8 100644
--- a/internal/podman/quadlet_embed.go
+++ b/internal/podman/quadlet_embed.go
@@ -349,6 +349,7 @@ func PairIPv6Binds(content string) string {
}
lines := strings.Split(content, "\n")
+ v4LoopbackPortSpecs := map[string]bool{}
v6PortSpecs := map[string]bool{}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
@@ -356,6 +357,10 @@ func PairIPv6Binds(content string) string {
continue
}
value := strings.TrimPrefix(trimmed, "PublishPort=")
+ if strings.HasPrefix(value, "127.0.0.1:") {
+ v4LoopbackPortSpecs[strings.TrimPrefix(value, "127.0.0.1:")] = true
+ continue
+ }
if !strings.HasPrefix(value, "[") {
continue
}
@@ -376,6 +381,10 @@ func PairIPv6Binds(content string) string {
value := strings.TrimPrefix(trimmed, "PublishPort=")
if strings.HasPrefix(value, "[") {
out = append(out, line)
+ if rest, ok := strings.CutPrefix(value, "[::1]:"); ok && !v4LoopbackPortSpecs[rest] {
+ out = append(out, "PublishPort=127.0.0.1:"+rest)
+ v4LoopbackPortSpecs[rest] = true
+ }
continue
}
diff --git a/internal/services/launchd_darwin.go b/internal/services/launchd_darwin.go
index 783d92a22..014511cd2 100644
--- a/internal/services/launchd_darwin.go
+++ b/internal/services/launchd_darwin.go
@@ -594,12 +594,14 @@ func (m *darwinServiceManager) ListServiceUnits(nameGlob string) []string {
// --- Container unit files ---
func (m *darwinServiceManager) WriteContainerUnit(name, content string) error {
- // Apply LAN binding restriction before parsing — mirrors WriteQuadletDiff on Linux.
+ // Apply the same unit-aware LAN policy as the Linux quadlet writer.
lanExposed := false
+ servicesExposed := false
if cfg, err := config.LoadGlobal(); err == nil && cfg != nil {
lanExposed = cfg.LAN.Exposed
+ servicesExposed = cfg.LAN.ServicesExposed
}
- content = podman.BindForLAN(content, lanExposed)
+ content = podman.BindQuadletForLAN(name, content, lanExposed, servicesExposed)
// gvproxy (macOS) cannot bind two specific host IPs on the same port;
// drop IPv6 PublishPort lines so only IPv4 bindings reach podman run.
content = stripIPv6PublishPorts(content)
diff --git a/internal/tray/list_test.go b/internal/tray/list_test.go
index de6b1f476..c231f5121 100644
--- a/internal/tray/list_test.go
+++ b/internal/tray/list_test.go
@@ -225,3 +225,22 @@ func TestToggleTitle(t *testing.T) {
t.Errorf("toggleTitle(off) = %q", got)
}
}
+
+func TestManagedServiceLANTitleDistinguishesEffectiveState(t *testing.T) {
+ cases := []struct {
+ name string
+ snap Snapshot
+ want string
+ }{
+ {"off", Snapshot{}, "Managed service LAN access: Off"},
+ {"armed", Snapshot{LANServicesExposed: true}, "Managed service LAN access: Armed (LAN exposure off)"},
+ {"active", Snapshot{LANExposed: true, LANServicesExposed: true}, "Managed service LAN access: ✔ On"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := managedServiceLANTitle(&tc.snap); got != tc.want {
+ t.Fatalf("managedServiceLANTitle() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/tray/menu.go b/internal/tray/menu.go
index c92f645b7..f8e1b405d 100644
--- a/internal/tray/menu.go
+++ b/internal/tray/menu.go
@@ -32,6 +32,7 @@ type menuState struct {
mSettings *systray.MenuItem
mAutostart *systray.MenuItem
mLAN *systray.MenuItem
+ mLANServices *systray.MenuItem
mDumps *systray.MenuItem
mNotifications *systray.MenuItem
mIconStyle *systray.MenuItem
@@ -73,6 +74,7 @@ func buildMenu(mono bool) *menuState {
if runtime.GOOS != "darwin" {
m.mLAN = m.mSettings.AddSubMenuItem("Expose to LAN: Off", "Toggle whether lerd is reachable from other devices on the local network")
}
+ m.mLANServices = m.mSettings.AddSubMenuItem("Managed service LAN access: Off", "Allow remote access to managed service ports on trusted networks")
m.mDumps = m.mSettings.AddSubMenuItem("Debug bridge: Off", "Capture dump() / dd() into the lerd dashboard")
m.mNotifications = m.mSettings.AddSubMenuItem("Notifications: On", "Globally enable or disable lerd notifications")
// The high-contrast icon toggle only makes sense for the colour icon; in
@@ -182,6 +184,16 @@ func toggleTitle(label string, on bool) string {
return label + ": Off"
}
+func managedServiceLANTitle(snap *Snapshot) string {
+ if !snap.LANServicesExposed {
+ return "Managed service LAN access: Off"
+ }
+ if !snap.LANExposed {
+ return "Managed service LAN access: Armed (LAN exposure off)"
+ }
+ return "Managed service LAN access: ✔ On"
+}
+
// apply updates menu titles and visibility from a Snapshot.
func (m *menuState) apply(snap *Snapshot) {
if snap == nil {
@@ -232,6 +244,7 @@ func (m *menuState) apply(snap *Snapshot) {
if m.mLAN != nil {
m.mLAN.SetTitle(toggleTitle("Expose to LAN", snap.LANExposed))
}
+ m.mLANServices.SetTitle(managedServiceLANTitle(snap))
m.mDumps.SetTitle(toggleTitle("Debug bridge", snap.DumpsEnabled))
m.mNotifications.SetTitle(toggleTitle("Notifications", snap.NotificationsEnabled))
if m.mIconStyle != nil {
diff --git a/internal/tray/tray.go b/internal/tray/tray.go
index d6591ffae..bb9ade5cc 100644
--- a/internal/tray/tray.go
+++ b/internal/tray/tray.go
@@ -48,6 +48,7 @@ type Snapshot struct {
WorkersDown []string // sites lerd considers unhealthy, not merely stopped
AutostartEnabled bool
LANExposed bool // lerd lan expose state — drives the LAN toggle item
+ LANServicesExposed bool // explicit managed-service LAN access preference
DumpsEnabled bool // lerd dump on/off state — drives the dump toggle item
NotificationsEnabled bool // lerd notify on/off state — drives the notifications toggle item
HighContrastIcon bool // lerd tray icon high-contrast state — green running icon on any panel
@@ -243,6 +244,7 @@ func onReady(mono bool) {
if menu.mLAN != nil {
go handleLAN(menu.mLAN, refresh)
}
+ go handleLANServices(menu.mLANServices, refresh)
go handleDumps(menu.mDumps, refresh)
go handleNotifications(menu.mNotifications, refresh)
if menu.mIconStyle != nil {
@@ -321,6 +323,7 @@ func fetchSnapshot() *Snapshot {
// for each toggle.
if cfg, err := config.LoadGlobal(); err == nil && cfg != nil {
snap.LANExposed = cfg.LAN.Exposed
+ snap.LANServicesExposed = cfg.LAN.ServicesExposed
snap.DumpsEnabled = cfg.IsDumpsEnabled()
snap.NotificationsEnabled = cfg.IsNotificationsEnabled()
snap.HighContrastIcon = cfg.IsHighContrastTrayIcon()
@@ -520,6 +523,16 @@ func handleLAN(item *systray.MenuItem, refresh func()) {
}
}
+func handleLANServices(item *systray.MenuItem, refresh func()) {
+ for range item.ClickedCh {
+ enabled := false
+ if cfg, err := config.LoadGlobal(); err == nil && cfg != nil {
+ enabled = cfg.LAN.ServicesExposed
+ }
+ runAndRefresh(lerdCmd("lan", "services", offOn(enabled)), refresh)
+ }
+}
+
func handleDumps(item *systray.MenuItem, refresh func()) {
for range item.ClickedCh {
enabled := false
diff --git a/internal/tui/settings.go b/internal/tui/settings.go
index 8c70995b8..ec0ec8bb0 100644
--- a/internal/tui/settings.go
+++ b/internal/tui/settings.go
@@ -22,6 +22,7 @@ type settingsKind int
const (
settingsLANExpose settingsKind = iota
+ settingsLANServices
settingsAutostart
settingsXdebug
settingsWorkerMode
@@ -34,9 +35,14 @@ func (m *Model) settingsRows() []settingsRow {
lanExposed := cfg != nil && cfg.LAN.Exposed
rows = append(rows, settingsRow{
kind: settingsLANExpose,
- label: "LAN expose (open every service to the local network)",
+ label: "LAN expose (sites and DNS)",
on: lanExposed,
})
+ rows = append(rows, settingsRow{
+ kind: settingsLANServices,
+ label: managedServiceLANLabel(cfg),
+ on: cfg != nil && cfg.LAN.ServicesExposed,
+ })
rows = append(rows, settingsRow{
kind: settingsAutostart,
label: "Autostart lerd on login",
@@ -88,6 +94,17 @@ func (m *Model) settingsToggle(rows []settingsRow) tea.Cmd {
}
m.setStatus("toggling LAN expose "+verb+"…", 5*time.Second)
return runLerd("", "lan", "expose", verb)
+ case settingsLANServices:
+ verb := "on"
+ if row.on {
+ verb = "off"
+ }
+ if row.on {
+ m.setStatus("disabling managed service LAN access…", 5*time.Second)
+ } else {
+ m.setStatus("enabling managed service LAN access — trusted networks only…", 5*time.Second)
+ }
+ return runLerd("", "lan", "services", verb)
case settingsAutostart:
sub := "enable"
if row.on {
@@ -115,3 +132,10 @@ func (m *Model) settingsToggle(rows []settingsRow) tea.Cmd {
}
return nil
}
+
+func managedServiceLANLabel(cfg *config.GlobalConfig) string {
+ if cfg != nil && cfg.LAN.ServicesExposed && !cfg.LAN.Exposed {
+ return "Managed service LAN access (inactive — LAN exposure off)"
+ }
+ return "Managed service LAN access"
+}
diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go
index b2df3ba3b..e5525d02c 100644
--- a/internal/tui/settings_test.go
+++ b/internal/tui/settings_test.go
@@ -3,6 +3,8 @@ package tui
import (
"runtime"
"testing"
+
+ "github.com/geodro/lerd/internal/config"
)
// The worker-mode row should only be present on macOS so the Linux
@@ -25,3 +27,30 @@ func TestSettingsRows_WorkerModeVisibilityMatchesPlatform(t *testing.T) {
found, runtime.GOOS, wantPresent)
}
}
+
+func TestSettingsRowsReflectManagedServiceLANExposure(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ cfg := &config.GlobalConfig{}
+ cfg.LAN.ServicesExposed = true
+ if err := config.SaveGlobal(cfg); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+
+ rows := NewModel("test").settingsRows()
+ for _, row := range rows {
+ if row.kind == settingsLANServices {
+ if !row.on {
+ t.Fatal("managed-service LAN row did not reflect enabled config")
+ }
+ if row.label != "Managed service LAN access (inactive — LAN exposure off)" {
+ t.Fatalf("row label = %q, want inactive state", row.label)
+ }
+ cfg.LAN.Exposed = true
+ if got := managedServiceLANLabel(cfg); got != "Managed service LAN access" {
+ t.Fatalf("active label = %q", got)
+ }
+ return
+ }
+ }
+ t.Fatal("managed-service LAN row is missing")
+}
diff --git a/internal/tui/system.go b/internal/tui/system.go
index 8defc3b26..b16160dd5 100644
--- a/internal/tui/system.go
+++ b/internal/tui/system.go
@@ -28,6 +28,7 @@ const (
sysProfiler
sysAutostart
sysLANExpose
+ sysLANServices
sysWorkerMode
sysXdebug
)
@@ -186,7 +187,8 @@ func (m *Model) systemRows() []systemRow {
}
}
add(systemRow{kind: sysAutostart, label: "Autostart on login", on: lerdSystemd.IsAutostartEnabled()})
- add(systemRow{kind: sysLANExpose, label: "LAN expose (every service)", on: cfg != nil && cfg.LAN.Exposed})
+ add(systemRow{kind: sysLANExpose, label: "LAN expose (sites and DNS)", on: cfg != nil && cfg.LAN.Exposed})
+ add(systemRow{kind: sysLANServices, label: managedServiceLANLabel(cfg), on: cfg != nil && cfg.LAN.ServicesExposed})
return rows
}
@@ -260,6 +262,17 @@ func (m *Model) systemToggle(rows []systemRow) tea.Cmd {
}
m.setStatus("LAN expose "+verb+"…", 5*time.Second)
return runLerd("", "lan", "expose", verb)
+ case sysLANServices:
+ verb := "on"
+ if row.on {
+ verb = "off"
+ }
+ if row.on {
+ m.setStatus("disabling managed service LAN access…", 5*time.Second)
+ } else {
+ m.setStatus("enabling managed service LAN access — trusted networks only…", 5*time.Second)
+ }
+ return runLerd("", "lan", "services", verb)
case sysWorkerMode:
target := config.WorkerExecModeContainer
if row.on {
diff --git a/internal/ui/app_logs_clear.go b/internal/ui/app_logs_clear.go
index a0e820614..f312f32a7 100644
--- a/internal/ui/app_logs_clear.go
+++ b/internal/ui/app_logs_clear.go
@@ -9,9 +9,8 @@ import (
"github.com/geodro/lerd/internal/config"
)
-// handleAppLogsClear deletes the project's application log files (the same set
-// the App Logs viewer lists) to reclaim disk, reporting how many files and
-// bytes were freed. Loopback-only — it deletes files on the host.
+// handleAppLogsClear deletes the matched application log files (the same files
+// the App Logs viewer lists). It requires dashboard-control authority.
func handleAppLogsClear(w http.ResponseWriter, basePath string, sources []config.FrameworkLogSource) {
files, bytes, err := clearAppLogs(basePath, sources)
resp := map[string]any{"ok": err == nil, "files_cleared": files, "bytes_cleared": bytes}
diff --git a/internal/ui/cleanup.go b/internal/ui/cleanup.go
index 7aa355bde..0bf332d1c 100644
--- a/internal/ui/cleanup.go
+++ b/internal/ui/cleanup.go
@@ -93,9 +93,8 @@ func invalidateDiskCache() {
}
// handleDisk serves the reclaimable-disk preview (GET) and runs the reclaim
-// (POST). The POST is loopback-only: the deep scope removes images on the host,
-// including dangling ones from other podman workloads, so it stays off the LAN
-// even when remote control is on.
+// (POST). The POST requires dashboard-control authority because the deep scope
+// removes images on the host, including dangling images from other workloads.
func handleDisk(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
diff --git a/internal/ui/commands.go b/internal/ui/commands.go
index a3edea924..1da34ca94 100644
--- a/internal/ui/commands.go
+++ b/internal/ui/commands.go
@@ -73,11 +73,11 @@ func commandRoute(w http.ResponseWriter, r *http.Request, domain string, rest []
}
switch {
case len(rest) == 1 && r.Method == http.MethodGet:
- // List is read-only and safe to expose to LAN viewers.
+ // Listing commands does not mutate the host.
handleCommandsList(w, r, site)
case len(rest) == 3 && rest[2] == "run" && r.Method == http.MethodPost:
- // Run executes arbitrary shell as the lerd-ui user. Loopback-only
- // so a LAN client can't trigger commands on the host.
+ // Running a command requires dashboard-control authority because it
+ // executes arbitrary shell code as the lerd-ui user.
if !isLoopbackRequest(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return true
diff --git a/internal/ui/commands_test.go b/internal/ui/commands_test.go
index 3d516a4f0..4881a70a9 100644
--- a/internal/ui/commands_test.go
+++ b/internal/ui/commands_test.go
@@ -226,7 +226,7 @@ commands:
rec := httptest.NewRecorder()
handleSiteAction(rec, req)
if rec.Code != http.StatusOK {
- t.Errorf("list endpoint must allow LAN viewers (read-only): %d %s", rec.Code, rec.Body.String())
+ t.Errorf("list endpoint must allow read-only requests: %d %s", rec.Code, rec.Body.String())
}
}
diff --git a/internal/ui/dashproxy.go b/internal/ui/dashproxy.go
index 50cb09815..4fd38e703 100644
--- a/internal/ui/dashproxy.go
+++ b/internal/ui/dashproxy.go
@@ -259,7 +259,7 @@ func resolveDashboardURL(svc *config.CustomService, services map[string]config.S
}
// handleDashProxy serves a bundled service dashboard same-origin under
-// /_svc//. Loopback-only, since it forwards into a local admin UI.
+// /_svc//. It requires dashboard-control authority.
func handleDashProxy(w http.ResponseWriter, r *http.Request) {
if !isLoopbackRequest(r) {
http.Error(w, "forbidden", http.StatusForbidden)
diff --git a/internal/ui/devtools.go b/internal/ui/devtools.go
index 3dab44411..c9fbfdae7 100644
--- a/internal/ui/devtools.go
+++ b/internal/ui/devtools.go
@@ -37,7 +37,7 @@ func buildDevtoolsStatusJSON() []byte {
}
// handleDevtoolsWorkers toggles capture of queue/scheduler worker queries.
-// Loopback-only, same trust boundary as the enable toggle.
+// It requires dashboard-control authority, like the enable toggle.
func handleDevtoolsWorkers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/dumps.go b/internal/ui/dumps.go
index d31f19c40..3a15d0272 100644
--- a/internal/ui/dumps.go
+++ b/internal/ui/dumps.go
@@ -222,8 +222,8 @@ func writeSSEEvent(w http.ResponseWriter, flusher http.Flusher, ev dumps.Event)
flusher.Flush()
}
-// handleDumpsClear empties the receiver's ring. Restricted to loopback so a
-// LAN client can't wipe a developer's working buffer.
+// handleDumpsClear empties the receiver's ring. It requires dashboard-control
+// authority because it deletes the developer's working buffer.
func handleDumpsClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -241,8 +241,8 @@ func handleDumpsClear(w http.ResponseWriter, r *http.Request) {
}
// handleDumpsPassthrough flips Dumps.Passthrough by delegating to
-// dumpsops.SetPassthrough. Loopback-only because this restarts every
-// installed FPM container — same trust boundary as the toggle.
+// dumpsops.SetPassthrough. It requires dashboard-control authority because it
+// restarts every installed FPM container.
func handleDumpsPassthrough(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -285,8 +285,7 @@ func handleDumpsNotifyChanged(w http.ResponseWriter, r *http.Request) {
}
// handleDumpsToggle flips Dumps.Enabled by delegating to dumpsops.Apply,
-// then returns the post-state JSON. Loopback-only so LAN clients can't
-// toggle capture state without authorization.
+// then returns the post-state JSON. It requires dashboard-control authority.
func handleDumpsToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/editor.go b/internal/ui/editor.go
index 84bb5a76c..a37895754 100644
--- a/internal/ui/editor.go
+++ b/internal/ui/editor.go
@@ -14,10 +14,9 @@ import (
"github.com/geodro/lerd/internal/config"
)
-// handleOpenEditor opens a file at a line in the user's editor, for the
-// "open in editor" links in the dashboard (e.g. a query's caller path).
-// Loopback-only: it execs a process on the host, so only a local browser
-// session may trigger it. Paths are confined to the user's home directory.
+// handleOpenEditor opens a file at a line in the host's editor for dashboard
+// links such as a query's caller path. It requires dashboard-control authority,
+// and paths are confined to the user's home directory.
func handleOpenEditor(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/lan_status_test.go b/internal/ui/lan_status_test.go
new file mode 100644
index 000000000..72e4c2110
--- /dev/null
+++ b/internal/ui/lan_status_test.go
@@ -0,0 +1,129 @@
+package ui
+
+import (
+ "bufio"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/geodro/lerd/internal/config"
+)
+
+func TestLANStatusIncludesManagedServiceExposure(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ cfg := &config.GlobalConfig{}
+ cfg.LAN.Exposed = true
+ cfg.LAN.ServicesExposed = true
+ if err := config.SaveGlobal(cfg); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/lan/status", nil)
+ rec := httptest.NewRecorder()
+ handleLANStatus(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Exposed bool `json:"exposed"`
+ ServicesEnabled bool `json:"services_enabled"`
+ ServicesReachable bool `json:"services_reachable"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !body.Exposed || !body.ServicesEnabled || !body.ServicesReachable {
+ t.Fatalf("response = %+v, want enabled and reachable services", body)
+ }
+}
+
+func TestLANStatusCanToggleManagedServiceExposureFromLoopback(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ if err := config.SaveGlobal(&config.GlobalConfig{}); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/lan/status", strings.NewReader(`{"action":"services_on"}`))
+ req.RemoteAddr = "127.0.0.1:12345"
+ req.Host = "localhost:7073"
+ rec := httptest.NewRecorder()
+ handleLANStatus(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if !cfg.LAN.ServicesExposed {
+ t.Fatal("services_on did not persist managed-service exposure")
+ }
+
+ var final struct {
+ Result string `json:"result"`
+ ServicesEnabled bool `json:"services_enabled"`
+ ServicesReachable bool `json:"services_reachable"`
+ }
+ scanner := bufio.NewScanner(rec.Body)
+ for scanner.Scan() {
+ var event struct {
+ Result string `json:"result"`
+ ServicesEnabled bool `json:"services_enabled"`
+ ServicesReachable bool `json:"services_reachable"`
+ }
+ if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Result != "" {
+ final = event
+ }
+ }
+ if final.Result != "ok" || !final.ServicesEnabled {
+ t.Fatalf("final event = %+v", final)
+ }
+ if final.ServicesReachable {
+ t.Fatalf("services must remain unreachable while LAN exposure is off: %+v", final)
+ }
+}
+
+func TestLANStatusRejectsUnauthenticatedRemoteManagedServiceToggle(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/lan/status", strings.NewReader(`{"action":"services_on"}`))
+ req.RemoteAddr = "192.0.2.10:12345"
+ rec := httptest.NewRecorder()
+ handleLANStatus(rec, req)
+
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+}
+
+func TestLANStatusRejectsUnauthenticatedLoopbackReverseProxy(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/lan/status", strings.NewReader(`{"action":"services_on"}`))
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "robotbox.example.net"
+ rec := httptest.NewRecorder()
+ handleLANStatus(rec, req)
+
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+}
+
+func TestAccessModeRejectsUnauthenticatedLoopbackReverseProxy(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/access-mode", nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "robotbox.example.net"
+ rec := httptest.NewRecorder()
+ handleAccessMode(rec, req)
+
+ var body struct {
+ LocalControl bool `json:"local_control"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if body.LocalControl {
+ t.Fatalf("response = %+v, reverse proxy must not grant local control", body)
+ }
+}
diff --git a/internal/ui/logterminal.go b/internal/ui/logterminal.go
index 1f1206e6d..a03be0b22 100644
--- a/internal/ui/logterminal.go
+++ b/internal/ui/logterminal.go
@@ -60,9 +60,8 @@ func handleUnitLogStream(w http.ResponseWriter, r *http.Request) {
// without launching a real emulator.
var openTerminal = openTerminalCommand
-// handleLogTerminal opens the user's terminal emulator tailing the same unit
+// handleLogTerminal opens the host's terminal emulator tailing the same unit
// the given log stream path shows, so a long-running tail can outlive the tab.
-// Loopback-only, see loopbackOnlyRoutes.
func handleLogTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/notify_target_http.go b/internal/ui/notify_target_http.go
index 354f04cd2..5f4e8d497 100644
--- a/internal/ui/notify_target_http.go
+++ b/internal/ui/notify_target_http.go
@@ -69,8 +69,8 @@ func handleNotifyTarget(w http.ResponseWriter, r *http.Request) {
}
}
-// handleNotifyKinds sets one native category on or off. Loopback-only; the
-// browser sink's per-category prefs stay per-device in the page.
+// handleNotifyKinds sets one native category on or off. It requires
+// dashboard-control authority; browser preferences remain per-device.
func handleNotifyKinds(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/openfolder.go b/internal/ui/openfolder.go
index f77fb0573..36a0c0016 100644
--- a/internal/ui/openfolder.go
+++ b/internal/ui/openfolder.go
@@ -12,8 +12,8 @@ import (
)
// handleOpenFolder opens a directory in the host's file manager (xdg-open on
-// Linux, open on macOS). Loopback-only and confined to the user's home, the
-// same guards as handleOpenEditor. Backs the clickable path on a site's header.
+// Linux, open on macOS). It requires dashboard-control authority and confines
+// paths to the user's home directory, like handleOpenEditor.
func handleOpenFolder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/profiler.go b/internal/ui/profiler.go
index cf29c1849..ea42c71a2 100644
--- a/internal/ui/profiler.go
+++ b/internal/ui/profiler.go
@@ -57,8 +57,8 @@ func buildProfilerStatusJSON() []byte {
return b
}
-// handleProfilerClear deletes every captured SPX report. Loopback-only: it
-// removes files from the shared profiler data directory.
+// handleProfilerClear deletes every captured SPX report. It requires
+// dashboard-control authority because it removes files from the host.
func handleProfilerClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/remote_control.go b/internal/ui/remote_control.go
index 2a9042515..92b841439 100644
--- a/internal/ui/remote_control.go
+++ b/internal/ui/remote_control.go
@@ -1,6 +1,7 @@
package ui
import (
+ "context"
"crypto/subtle"
"encoding/json"
"net"
@@ -15,43 +16,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
-// loopbackOnlyRoutes are dashboard endpoints that perform actions too
-// destructive or sensitive to allow from a remote (LAN) client even when
-// remote-control is enabled with valid Basic auth credentials. Examples:
-// shutting lerd down entirely, opening a terminal on the host, linking
-// arbitrary host filesystem paths as new sites. The local user can still
-// use them as normal because loopback bypasses everything.
-var loopbackOnlyRoutes = []string{
- "/api/lerd/stop", // shuts down all lerd containers
- "/api/lerd/quit", // exits the dashboard process
- "/api/lerd/update-terminal", // spawns a terminal emulator on the host
- "/api/logs/terminal", // spawns a terminal emulator on the host
- "/api/sites/link", // links arbitrary host filesystem paths
- "/api/browse", // browses host filesystem
- "/api/push/test", // fires notifications onto subscribed devices
-}
-
-// loopbackOnlyRoutePrefixes are endpoint subtrees restricted to loopback in
-// full, so a new subresource cannot escape by failing to be listed. Databases
-// read out, drop and overwrite the data the "/env" gate already protects.
-var loopbackOnlyRoutePrefixes = []string{
- "/api/databases",
- "/api/entities",
- // Replaces executables on the host's PATH, so it stays with the terminal
- // and link routes rather than behind Basic auth alone.
- "/api/tools",
-}
-
-// loopbackOnlySiteSubactions are the per-site actions (under
-// /api/sites/{domain}/) whose entire subtree is restricted to loopback.
-// A subaction "/env" gates /api/sites/{d}/env and every nested route
-// under it (e.g. /env/files, /env/backups, /env/backups/,
-// /env/restore), so adding a new subresource cannot accidentally escape
-// the LAN gate by failing to be re-listed here.
-var loopbackOnlySiteSubactions = []string{
- "/terminal", // opens an interactive shell on the host
- "/env", // raw .env content + backups + restore (APP_KEY, DB creds, tokens)
-}
+type ctxKeyRemoteDashboard struct{}
// fromHost reports whether r's source IP belongs to one of the host's
// own interfaces. The mailpit container reaches the dashboard via
@@ -95,40 +60,6 @@ func fromHost(r *http.Request) bool {
return false
}
-// isLoopbackOnlyPath reports whether the given URL path is in either
-// the exact-match list or matches a per-site action whose entire subtree
-// is loopback-only. A subaction "/env" matches /api/sites/{d}/env exactly
-// and any subroute under it (/env/files, /env/backups, /env/restore,
-// /env/backups/), so adding a new subresource never silently
-// escapes the gate.
-func isLoopbackOnlyPath(path string) bool {
- for _, p := range loopbackOnlyRoutes {
- if path == p {
- return true
- }
- }
- for _, p := range loopbackOnlyRoutePrefixes {
- if path == p || strings.HasPrefix(path, p+"/") {
- return true
- }
- }
- if !strings.HasPrefix(path, "/api/sites/") {
- return false
- }
- rest := strings.TrimPrefix(path, "/api/sites/")
- slash := strings.Index(rest, "/")
- if slash < 0 {
- return false
- }
- after := rest[slash:]
- for _, action := range loopbackOnlySiteSubactions {
- if after == action || strings.HasPrefix(after, action+"/") {
- return true
- }
- }
- return false
-}
-
// unsafeMethod reports whether m can mutate server state and therefore must
// pass the cross-origin gate. Read-only methods (GET, HEAD, OPTIONS) can't,
// so a forged one does no harm.
@@ -205,13 +136,10 @@ func passesCSRF(r *http.Request) bool {
// true | empty | 403 (no credentials configured)
// true | set | require HTTP Basic auth
//
-// Loopback (127.x, ::1) always bypasses both checks. OPTIONS preflight
-// passes through (no Authorization header expected). /api/remote-setup
-// has its own token + IP gate and is unaffected.
-//
-// Additionally, the loopbackOnlyRoutes list (lerd stop/quit, site link,
-// terminal, filesystem browse) is rejected from non-loopback even with
-// valid Basic auth. The local user keeps full access via loopback.
+// Direct local dashboard requests bypass both checks. OPTIONS preflight
+// passes through because it has no Authorization header. /api/remote-setup
+// has its own token and IP gate. An authenticated remote dashboard receives
+// the same controls as the local dashboard.
func withRemoteControlGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. CORS preflight: pass through. Browsers don't include the
@@ -230,7 +158,7 @@ func withRemoteControlGate(next http.Handler) http.Handler {
// by non-browser clients that have their own source protection.
if unsafeMethod(r.Method) && !csrfExemptPath(r.URL.Path) && !passesCSRF(r) {
w.Header().Set("Cache-Control", "no-store")
- http.Error(w, "Forbidden — cross-origin request blocked. Use the lerd dashboard on this machine.", http.StatusForbidden)
+ http.Error(w, "Forbidden — cross-origin request blocked. Use the Lerd dashboard itself.", http.StatusForbidden)
return
}
@@ -253,23 +181,13 @@ func withRemoteControlGate(next http.Handler) http.Handler {
return
}
- // 3. Loopback (127.x, ::1) always bypasses. The local user owns the
- // machine and can never be locked out.
- if isLoopbackRequest(r) {
+ // 3. Only direct host control bypasses authentication. A reverse proxy
+ // may connect from 127.0.0.1 on behalf of a remote browser.
+ if isLocalControlRequest(r) {
next.ServeHTTP(w, r)
return
}
- // 3a. Loopback-only routes: even with valid Basic auth, certain
- // destructive actions (lerd stop, terminal, site link, filesystem
- // browse) are not allowed from non-loopback sources. The local
- // user can still trigger them via loopback.
- if isLoopbackOnlyPath(r.URL.Path) {
- w.Header().Set("Cache-Control", "no-store")
- http.Error(w, "Forbidden — this action is only available from the lerd host (loopback).", http.StatusForbidden)
- return
- }
-
// 4. Non-loopback path. Inspect the configured LAN/remote-control
// state. All gate responses set Cache-Control: no-store so
// browsers don't replay an old 403/401 after the user enables
@@ -304,7 +222,7 @@ func withRemoteControlGate(next http.Handler) http.Handler {
now := time.Now()
if c, err := r.Cookie(remoteSessionCookie); err == nil &&
remoteSessionValid(c.Value, cfg.UI.Username, cfg.UI.PasswordHash, now) {
- next.ServeHTTP(w, r)
+ serveRemoteDashboard(next, w, r)
return
}
@@ -332,53 +250,62 @@ func withRemoteControlGate(next http.Handler) http.Handler {
// Basic auth cleared — mint a session cookie so the browser skips
// the challenge on subsequent requests.
setRemoteSessionCookie(w, cfg.UI.Username, cfg.UI.PasswordHash, now)
- next.ServeHTTP(w, r)
+ serveRemoteDashboard(next, w, r)
})
}
-// handleAccessMode serves /api/access-mode. Returns whether the request
-// came from loopback so the frontend can hide UI elements that map to
-// loopback-only endpoints (the terminal button, the link-site button, the
-// stop-lerd button). Reachable from any source — there's no sensitive
-// information here.
+func serveRemoteDashboard(next http.Handler, w http.ResponseWriter, r *http.Request) {
+ ctx := context.WithValue(r.Context(), ctxKeyRemoteDashboard{}, true)
+ next.ServeHTTP(w, r.WithContext(ctx))
+}
+
+// handleAccessMode serves /api/access-mode. It reports whether this request
+// has dashboard-control authority and whether LAN exposure is enabled.
func handleAccessMode(w http.ResponseWriter, r *http.Request) {
+ cfg, _ := config.LoadGlobal()
+ lanExposed := cfg != nil && cfg.LAN.Exposed
writeJSON(w, map[string]any{
- "loopback": isLoopbackRequest(r),
+ "local_control": hasDashboardControl(r),
+ "lan_exposed": lanExposed,
})
}
// handleLANStatus serves /api/lan/status.
//
-// GET → { exposed, lan_ip }
-// POST { action: "expose" } → flips lerd to LAN-exposed mode
-// POST { action: "unexpose" } → flips lerd back to loopback-only mode
+// GET → { exposed, services_enabled, services_reachable, lan_ip }
+// POST { action: "expose" } → exposes sites, DNS, and dashboard bind
+// POST { action: "unexpose" } → returns every endpoint to loopback
+// POST { action: "services_on" } → opts managed services into LAN access
+// POST { action: "services_off" } → returns managed services to loopback
//
-// POST is gated to loopback inside the handler because it rewrites systemd
-// user units, container quadlets, and dnsmasq config on the host.
+// POST requires dashboard-control authority because it rewrites runtime units
+// and host configuration.
func handleLANStatus(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, _ := config.LoadGlobal()
exposed := false
+ servicesEnabled := false
if cfg != nil {
exposed = cfg.LAN.Exposed
+ servicesEnabled = cfg.LAN.ServicesExposed
}
lanIP := ""
if exposed {
lanIP = uiPrimaryLANIP()
}
writeJSON(w, map[string]any{
- "exposed": exposed,
- "lan_ip": lanIP,
- "macos": runtime.GOOS == "darwin",
+ "exposed": exposed,
+ "services_enabled": servicesEnabled,
+ "services_reachable": exposed && servicesEnabled,
+ "lan_ip": lanIP,
+ "macos": runtime.GOOS == "darwin",
})
return
case http.MethodPost:
- // POST touches systemd units and quadlets on the host — never allow
- // from LAN even if the caller has valid Basic auth.
- if !isLoopbackRequest(r) {
- http.Error(w, "Forbidden — LAN exposure can only be toggled from the lerd host (loopback).", http.StatusForbidden)
+ if !hasDashboardControl(r) {
+ http.Error(w, "Forbidden — dashboard authentication is required to change LAN exposure.", http.StatusForbidden)
return
}
var body struct {
@@ -388,8 +315,10 @@ func handleLANStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
- if body.Action != "expose" && body.Action != "unexpose" {
- http.Error(w, "unknown action — expected 'expose' or 'unexpose'", http.StatusBadRequest)
+ switch body.Action {
+ case "expose", "unexpose", "services_on", "services_off":
+ default:
+ http.Error(w, "unknown action — expected 'expose', 'unexpose', 'services_on', or 'services_off'", http.StatusBadRequest)
return
}
@@ -412,6 +341,13 @@ func handleLANStatus(w http.ResponseWriter, r *http.Request) {
progress := func(step string) {
writeLine(map[string]any{"step": step})
}
+ serviceState := func() (enabled, reachable bool) {
+ cfg, _ := config.LoadGlobal()
+ if cfg == nil {
+ return false, false
+ }
+ return cfg.LAN.ServicesExposed, cfg.LAN.Exposed && cfg.LAN.ServicesExposed
+ }
switch body.Action {
case "expose":
@@ -420,14 +356,41 @@ func handleLANStatus(w http.ResponseWriter, r *http.Request) {
writeLine(map[string]any{"result": "error", "error": err.Error()})
return
}
- writeLine(map[string]any{"result": "ok", "exposed": true, "lan_ip": lanIP})
+ enabled, reachable := serviceState()
+ writeLine(map[string]any{
+ "result": "ok",
+ "exposed": true,
+ "services_enabled": enabled,
+ "services_reachable": reachable,
+ "lan_ip": lanIP,
+ })
return
case "unexpose":
if err := lerdcli.DisableLANExposure(progress); err != nil {
writeLine(map[string]any{"result": "error", "error": err.Error()})
return
}
- writeLine(map[string]any{"result": "ok", "exposed": false, "lan_ip": ""})
+ enabled, _ := serviceState()
+ writeLine(map[string]any{
+ "result": "ok",
+ "exposed": false,
+ "services_enabled": enabled,
+ "services_reachable": false,
+ "lan_ip": "",
+ })
+ return
+ case "services_on", "services_off":
+ enabled := body.Action == "services_on"
+ if err := lerdcli.SetManagedServiceLANExposure(enabled, progress); err != nil {
+ writeLine(map[string]any{"result": "error", "error": err.Error()})
+ return
+ }
+ serviceEnabled, reachable := serviceState()
+ writeLine(map[string]any{
+ "result": "ok",
+ "services_enabled": serviceEnabled,
+ "services_reachable": reachable,
+ })
return
}
@@ -552,17 +515,16 @@ func handleRemoteControl(w http.ResponseWriter, r *http.Request) {
}
// handleRemoteSetupGenerate serves /api/remote-setup/generate. POST creates a
-// fresh one-time setup token and returns the curl one-liner the laptop should
-// run. Loopback-only — generating a token from a remote browser would defeat
-// the whole gate. The corresponding /api/remote-setup endpoint (consumed by
-// the laptop) lives in remote_setup.go and has its own RFC 1918 + token gate.
+// fresh one-time setup token for a remote machine. It requires dashboard-control
+// authority. The corresponding /api/remote-setup endpoint consumed by that
+// machine has its own RFC 1918 source and one-time-token gates.
func handleRemoteSetupGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !isLoopbackRequest(r) {
- http.Error(w, "Forbidden — setup codes can only be generated from the lerd host (loopback).", http.StatusForbidden)
+ http.Error(w, "Forbidden — dashboard authentication is required to generate setup codes.", http.StatusForbidden)
return
}
if cfg, _ := config.LoadGlobal(); cfg != nil && !cfg.DNS.Enabled {
@@ -589,34 +551,72 @@ func handleRemoteSetupGenerate(w http.ResponseWriter, r *http.Request) {
})
}
-// isLoopbackRequest reports whether r should be treated as originating from
-// the local host. Three paths qualify:
-//
-// 1. The connection arrived over the unix socket listener. Only host
-// processes with filesystem access to the socket can connect, so this
-// is at least as trusted as TCP loopback. The lerd.localhost nginx
-// vhost reaches lerd-ui via this path.
-// 2. The TCP peer is a loopback IP (127.x, ::1). This catches direct visits
-// to http://localhost:7073 / http://127.0.0.1:7073.
-// 3. The request carries an X-Lerd-Trust header whose value matches the
-// per-install token. Kept for backward compatibility with old vhosts
-// that may still inject the header; new installs use the unix socket.
-func isLoopbackRequest(r *http.Request) bool {
+// isLocalControlRequest reports whether a request may control the lerd host.
+// Unix-socket requests and requests carrying the private nginx trust token are
+// authoritative. A direct TCP request must have both a loopback peer and a
+// loopback or RFC-reserved .localhost Host. Requiring both rejects reverse
+// proxies, such as Tailscale Serve, that connect from 127.0.0.1 on behalf of a
+// remote browser.
+
+func hasValidTrustToken(r *http.Request) bool {
+ claimed := r.Header.Get("X-Lerd-Trust")
+ if claimed == "" {
+ return false
+ }
+ token, err := nginx.LoadOrGenerateTrustToken()
+ return err == nil && token != "" &&
+ subtle.ConstantTimeCompare([]byte(claimed), []byte(token)) == 1
+}
+
+func isLocalControlRequest(r *http.Request) bool {
if v, _ := r.Context().Value(ctxKeyUnixSocket{}).(bool); v {
return true
}
- host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if hasValidTrustToken(r) {
+ return true
+ }
+ peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
- host = r.RemoteAddr
+ peer = r.RemoteAddr
}
- if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
+ ip := net.ParseIP(peer)
+ if ip == nil || !ip.IsLoopback() {
+ return false
+ }
+ host := r.Host
+ if parsed, _, err := net.SplitHostPort(r.Host); err == nil {
+ host = parsed
+ }
+ host = strings.Trim(strings.ToLower(host), "[]")
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
- if claimed := r.Header.Get("X-Lerd-Trust"); claimed != "" {
- token, err := nginx.LoadOrGenerateTrustToken()
- if err == nil && token != "" && subtle.ConstantTimeCompare([]byte(claimed), []byte(token)) == 1 {
- return true
- }
+ hostIP := net.ParseIP(host)
+ return hostIP != nil && hostIP.IsLoopback()
+}
+
+func hasDashboardControl(r *http.Request) bool {
+ if authenticated, _ := r.Context().Value(ctxKeyRemoteDashboard{}).(bool); authenticated {
+ return true
}
- return false
+ return isLocalControlRequest(r)
+}
+
+// isLoopbackRequest gates handlers that are also called directly in tests.
+// Authenticated dashboard requests receive the same authority as loopback.
+func isLoopbackRequest(r *http.Request) bool {
+ if authenticated, _ := r.Context().Value(ctxKeyRemoteDashboard{}).(bool); authenticated {
+ return true
+ }
+ if v, _ := r.Context().Value(ctxKeyUnixSocket{}).(bool); v {
+ return true
+ }
+ peer, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ peer = r.RemoteAddr
+ }
+ if ip := net.ParseIP(peer); ip != nil && ip.IsLoopback() {
+ return true
+ }
+ return hasValidTrustToken(r)
}
diff --git a/internal/ui/remote_control_test.go b/internal/ui/remote_control_test.go
index 8e6f9767a..9a9a647e3 100644
--- a/internal/ui/remote_control_test.go
+++ b/internal/ui/remote_control_test.go
@@ -2,6 +2,7 @@ package ui
import (
"context"
+ "encoding/json"
"net"
"net/http"
"net/http/httptest"
@@ -83,6 +84,7 @@ func TestRemoteControlGate_loopbackBypassesEverything(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
@@ -94,6 +96,64 @@ func TestRemoteControlGate_loopbackBypassesEverything(t *testing.T) {
}
}
+func TestRemoteControlGateReverseProxyDoesNotBypassAuthentication(t *testing.T) {
+ setupConfigDirRaw(t, "", "", true)
+ next := &nextHandler{}
+ gate := withRemoteControlGate(next)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "robotbox.example.net"
+ rec := httptest.NewRecorder()
+ gate.ServeHTTP(rec, req)
+
+ if next.called {
+ t.Fatal("loopback reverse proxy bypassed dashboard authentication")
+ }
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+}
+
+func TestRemoteControlGateAuthenticatedReverseProxyReceivesDashboardControl(t *testing.T) {
+ setupConfigDir(t, "alice", "s3cret")
+ gate := withRemoteControlGate(http.HandlerFunc(handleAccessMode))
+
+ req := httptest.NewRequest(http.MethodGet, "/api/access-mode", nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "robotbox.example.net"
+ req.SetBasicAuth("alice", "s3cret")
+ rec := httptest.NewRecorder()
+ gate.ServeHTTP(rec, req)
+
+ var body struct {
+ LocalControl bool `json:"local_control"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !body.LocalControl {
+ t.Fatalf("response = %+v, authenticated dashboard must receive full controls", body)
+ }
+}
+
+func TestRemoteControlGateAuthenticatedDashboardCanMutateLANSettings(t *testing.T) {
+ setupConfigDir(t, "alice", "s3cret")
+ gate := withRemoteControlGate(http.HandlerFunc(handleLANStatus))
+
+ req := httptest.NewRequest(http.MethodPost, "/api/lan/status", http.NoBody)
+ req.RemoteAddr = "192.168.1.42:54321"
+ req.Host = "robotbox.example.net"
+ req.SetBasicAuth("alice", "s3cret")
+ req.Header.Set("X-Lerd-CSRF", "1")
+ rec := httptest.NewRecorder()
+ gate.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400 after authenticated request reaches action validation", rec.Code)
+ }
+}
+
func TestRemoteControlGate_lanForbiddenWhenDisabled(t *testing.T) {
setupConfigDir(t, "", "") // no auth configured
@@ -202,15 +262,19 @@ func TestRemoteControlGate_sessionCookie(t *testing.T) {
}
t.Run("cookie authenticates without Basic header", func(t *testing.T) {
- next2 := &nextHandler{}
+ authorized := false
+ next2 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authorized = hasDashboardControl(r) && isLoopbackRequest(r)
+ w.WriteHeader(http.StatusOK)
+ })
gate2 := withRemoteControlGate(next2)
req2 := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
req2.RemoteAddr = "192.168.1.42:54321"
req2.AddCookie(session)
rec2 := httptest.NewRecorder()
gate2.ServeHTTP(rec2, req2)
- if !next2.called || rec2.Code != http.StatusOK {
- t.Errorf("session cookie did not authenticate, status=%d", rec2.Code)
+ if !authorized || rec2.Code != http.StatusOK {
+ t.Errorf("session cookie did not grant dashboard-control authority, status=%d", rec2.Code)
}
})
@@ -290,57 +354,22 @@ func TestRemoteControlGate_remoteSetupBypassesEvenWhenDisabled(t *testing.T) {
}
}
-func TestIsLoopbackOnlyPath(t *testing.T) {
- cases := []struct {
- path string
- want bool
- }{
- {"/api/lerd/stop", true},
- {"/api/lerd/quit", true},
- {"/api/logs/terminal", true},
- {"/api/logs/lerd-nginx", false},
- {"/api/sites/link", true},
- {"/api/browse", true},
- {"/api/sites/myapp.test/terminal", true},
- {"/api/sites/foo.bar.test/terminal", true},
- {"/api/sites/myapp.test/env", true},
- {"/api/sites/myapp.test/env/files", true},
- {"/api/sites/myapp.test/env/backups", true},
- {"/api/sites/myapp.test/env/backups/.env.bkp.20260528-103045", true},
- {"/api/sites/myapp.test/env/restore", true},
- {"/api/sites/myapp.test/terminal/anything", true},
- {"/api/databases", true},
- {"/api/databases/mysql", true},
- {"/api/databases/mysql/drop", true},
- {"/api/databases/mysql/export", true},
- {"/api/databases/postgres/snapshots/nightly", true},
- {"/api/databases-overview", false},
- {"/api/tools/composer/update", true},
- {"/api/share-tools", false},
- {"/api/sites", false},
- {"/api/sites/myapp.test", false},
- {"/api/sites/myapp.test/secure", false},
- {"/api/sites/myapp.test/envoy", false},
- {"/api/lerd/start", false},
- {"/api/version", false},
- {"/", false},
- }
- for _, c := range cases {
- t.Run(c.path, func(t *testing.T) {
- if got := isLoopbackOnlyPath(c.path); got != c.want {
- t.Errorf("isLoopbackOnlyPath(%q) = %v, want %v", c.path, got, c.want)
- }
- })
- }
-}
-
-func TestRemoteControlGate_loopbackOnlyRoutesBlockedFromLAN(t *testing.T) {
- setupConfigDir(t, "alice", "s3cret") // remote-control on with valid creds
-
- next := &nextHandler{}
+func TestRemoteControlGateAuthenticatedDashboardCanUseHostControlRoutes(t *testing.T) {
+ setupConfigDir(t, "alice", "s3cret")
+ called := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ if !hasDashboardControl(r) {
+ t.Error("authenticated dashboard did not receive dashboard-control authority")
+ }
+ if !isLoopbackRequest(r) {
+ t.Error("authenticated dashboard did not pass handler-level authority checks")
+ }
+ w.WriteHeader(http.StatusOK)
+ })
gate := withRemoteControlGate(next)
- cases := []string{
+ for _, path := range []string{
"/api/lerd/stop",
"/api/lerd/update-terminal",
"/api/logs/terminal",
@@ -353,38 +382,22 @@ func TestRemoteControlGate_loopbackOnlyRoutesBlockedFromLAN(t *testing.T) {
"/api/databases/mysql/drop",
"/api/databases/mysql/export",
"/api/databases/postgres/snapshots/nightly",
- }
- for _, path := range cases {
+ "/api/remote-setup/generate",
+ "/api/disk",
+ "/api/open-editor",
+ } {
t.Run(path, func(t *testing.T) {
+ called = false
req := httptest.NewRequest(http.MethodPost, path, nil)
req.RemoteAddr = "192.168.1.42:54321"
- req.SetBasicAuth("alice", "s3cret") // valid creds present
- req.Header.Set("X-Lerd-CSRF", "1") // clear the CSRF gate so we exercise the loopback-only check
+ req.Host = "robotbox.example.net"
+ req.SetBasicAuth("alice", "s3cret")
+ req.Header.Set("X-Lerd-CSRF", "1")
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
- if rec.Code != http.StatusForbidden {
- t.Errorf("status = %d, want 403 (loopback-only path from LAN)", rec.Code)
- }
- })
- }
-}
-
-func TestRemoteControlGate_loopbackOnlyRoutesAllowedFromLoopback(t *testing.T) {
- setupConfigDir(t, "", "")
-
- next := &nextHandler{}
- gate := withRemoteControlGate(next)
- for _, path := range []string{"/api/lerd/stop", "/api/sites/link", "/api/sites/myapp.test/terminal"} {
- t.Run(path, func(t *testing.T) {
- next.called = false
- req := httptest.NewRequest(http.MethodPost, path, nil)
- req.RemoteAddr = "127.0.0.1:54321"
- req.Header.Set("X-Lerd-CSRF", "1") // the real dashboard always sends this
- rec := httptest.NewRecorder()
- gate.ServeHTTP(rec, req)
- if !next.called {
- t.Errorf("loopback request to %s blocked", path)
+ if !called || rec.Code != http.StatusOK {
+ t.Fatalf("authenticated dashboard route %s: called=%v status=%d", path, called, rec.Code)
}
})
}
@@ -548,6 +561,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate := withRemoteControlGate(next)
req := httptest.NewRequest(http.MethodPost, tinker, nil)
req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Origin", "http://evil.example")
rec := httptest.NewRecorder()
@@ -565,6 +579,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate := withRemoteControlGate(next)
req := httptest.NewRequest(http.MethodPost, tinker, nil)
req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
req.Header.Set("Sec-Fetch-Site", "same-origin")
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
@@ -581,6 +596,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate := withRemoteControlGate(next)
req := httptest.NewRequest(http.MethodPost, tinker, nil)
req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Origin", "http://lerd.localhost")
rec := httptest.NewRecorder()
@@ -595,6 +611,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate := withRemoteControlGate(next)
req := httptest.NewRequest(http.MethodPost, tinker, nil)
req.RemoteAddr = "127.0.0.1:54321" // no Sec-Fetch, no X-Lerd-CSRF
+ req.Host = "localhost:7073"
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
if next.called || rec.Code != http.StatusForbidden {
@@ -605,6 +622,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate2 := withRemoteControlGate(next2)
req2 := httptest.NewRequest(http.MethodPost, tinker, nil)
req2.RemoteAddr = "127.0.0.1:54321"
+ req2.Host = "localhost:7073"
req2.Header.Set("X-Lerd-CSRF", "1")
rec2 := httptest.NewRecorder()
gate2.ServeHTTP(rec2, req2)
@@ -619,6 +637,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate := withRemoteControlGate(next)
req := httptest.NewRequest(m, "/api/sites", nil)
req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Origin", "http://evil.example")
rec := httptest.NewRecorder()
@@ -653,6 +672,7 @@ func TestRemoteControlGate_csrf(t *testing.T) {
gate := withRemoteControlGate(next)
req := httptest.NewRequest(http.MethodPost, path, nil)
req.RemoteAddr = "127.0.0.1:54321" // no Sec-Fetch, no X-Lerd-CSRF
+ req.Host = "localhost:7073"
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
if !next.called {
diff --git a/internal/ui/server.go b/internal/ui/server.go
index 63693bfa8..ad51b3130 100644
--- a/internal/ui/server.go
+++ b/internal/ui/server.go
@@ -215,9 +215,9 @@ func Start(currentVersion string) error {
mux.HandleFunc("/api/tunnel-qr/", withCORS(handleTunnelQR))
mux.HandleFunc("/api/dashboard-qr", withCORS(handleDashboardQR))
- // Cross-process notifier for CLI/MCP. Loopback-only. PollNow in a
- // goroutine so the handler returns under the CLI's 500 ms POST
- // timeout while the cache refresh drives the next WS broadcast.
+ // Cross-process notifier for CLI/MCP. It requires dashboard-control
+ // authority. PollNow runs in a goroutine so the handler returns under the
+ // CLI's 500 ms POST timeout while the next WebSocket broadcast refreshes.
mux.HandleFunc("/api/internal/notify", func(w http.ResponseWriter, r *http.Request) {
if !isLoopbackRequest(r) {
http.Error(w, "forbidden", http.StatusForbidden)
@@ -5247,9 +5247,9 @@ func handleLerdQuit(w http.ResponseWriter, r *http.Request) {
go cli.RunQuit() //nolint:errcheck
}
-// handleLerdUpdateTerminal opens the user's terminal emulator running
-// `lerd update`. Loopback-only. Uses os.Executable() because the spawned
-// `sh -c` doesn't source .bashrc, so ~/.local/bin is off PATH otherwise.
+// handleLerdUpdateTerminal opens the host's terminal emulator running
+// `lerd update`. It requires dashboard-control authority. Uses os.Executable()
+// because the spawned shell does not load the user's interactive PATH.
func handleLerdUpdateTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -5551,7 +5551,7 @@ func handleAppLogs(w http.ResponseWriter, r *http.Request) {
}
// POST /api/app-logs/{domain}/clear deletes the matched log files to reclaim
- // disk. Loopback-only since it mutates files on the host.
+ // disk. It requires dashboard-control authority.
if len(parts) == 2 && parts[1] == "clear" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/ui/site_doctor.go b/internal/ui/site_doctor.go
index 8f462c85c..f8e5007d5 100644
--- a/internal/ui/site_doctor.go
+++ b/internal/ui/site_doctor.go
@@ -7,8 +7,8 @@ import (
"github.com/geodro/lerd/internal/sitedoctor"
)
-// doctorRoute handles the doctor subroutes for a site. Loopback-only: checks and
-// fixes exec in the site's container, the same trust level as the command runner.
+// doctorRoute handles the doctor subroutes for a site. It requires
+// dashboard-control authority because checks and fixes execute in containers.
// Returns true when it owns the request. The check logic itself lives in
// internal/sitedoctor so the TUI and CLI share it.
//
diff --git a/internal/ui/web/demo/fixtures/lan_status.json b/internal/ui/web/demo/fixtures/lan_status.json
index a79b2237e..515d4d9fc 100644
--- a/internal/ui/web/demo/fixtures/lan_status.json
+++ b/internal/ui/web/demo/fixtures/lan_status.json
@@ -1 +1 @@
-{"exposed": false, "lan_ip": "", "macos": false}
\ No newline at end of file
+{"exposed": false, "services_enabled": false, "services_reachable": false, "lan_ip": "", "macos": false}
\ No newline at end of file
diff --git a/internal/ui/web/demo/stubs.ts b/internal/ui/web/demo/stubs.ts
index 6dcfdc61b..583f337c2 100644
--- a/internal/ui/web/demo/stubs.ts
+++ b/internal/ui/web/demo/stubs.ts
@@ -527,6 +527,26 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise>({
dashboard: m.nav_dashboard(),
diff --git a/internal/ui/web/src/components/NavRail.svelte b/internal/ui/web/src/components/NavRail.svelte
index 4a0ff5bde..4883e01bc 100644
--- a/internal/ui/web/src/components/NavRail.svelte
+++ b/internal/ui/web/src/components/NavRail.svelte
@@ -29,9 +29,9 @@
void loadProfilerStatus();
});
- // The profiler and service dashboards are loopback-only localhost web UIs, so
- // their launch icons are dead from a remote (LAN) dashboard. Hide them there.
- const remote = $derived(!$accessMode.loopback);
+ // Hide host-local launchers only when dashboard-control authority is
+ // unavailable. Authenticated remote dashboards receive authority.
+ const remote = $derived(!$accessMode.localControl);
const labels = $derived>({
dashboard: m.nav_dashboard(),
diff --git a/internal/ui/web/src/components/Toggle.svelte b/internal/ui/web/src/components/Toggle.svelte
index 0cf5fe83d..3f9c6d587 100644
--- a/internal/ui/web/src/components/Toggle.svelte
+++ b/internal/ui/web/src/components/Toggle.svelte
@@ -41,6 +41,7 @@
From 8c670c6d7dde3b033beded0e3e2be6fec41e25cd Mon Sep 17 00:00:00 2001
From: George Dumitrescu
Date: Fri, 31 Jul 2026 17:50:52 +0300
Subject: [PATCH 2/3] fix: keep LAN rebinds converging after a failed or
interrupted toggle
Restart failures no longer stop the rebind loop. Every affected unit is attempted and the failures are reported together, so one container that cannot come back does not decide the fate of the ones queued behind it.
Which units to restart is also no longer read from whether the file changed on this pass. A toggle that was interrupted leaves the quadlets already rewritten while the containers still run their old bind, and a later run then found nothing to do and reported success while a service was still answering on the network. Each installed container's published ports are now compared against the policy it should be following, so that drift is noticed and cleared instead of surviving every subsequent run.
---
internal/cli/dns.go | 20 ++--
internal/cli/lan_rebind_resilience_test.go | 106 ++++++++++++++++++++
internal/podman/lan_drift_test.go | 107 +++++++++++++++++++++
internal/podman/quadlet.go | 98 ++++++++++++++++---
4 files changed, 313 insertions(+), 18 deletions(-)
create mode 100644 internal/cli/lan_rebind_resilience_test.go
create mode 100644 internal/podman/lan_drift_test.go
diff --git a/internal/cli/dns.go b/internal/cli/dns.go
index 56963319e..4f7ad779f 100644
--- a/internal/cli/dns.go
+++ b/internal/cli/dns.go
@@ -1,6 +1,7 @@
package cli
import (
+ "errors"
"fmt"
"net"
"os"
@@ -211,21 +212,28 @@ func SetManagedServiceLANExposure(enabled bool, progress LANProgressFunc) error
// regenerateLANContainerQuadlets reapplies the current LAN bind policy to every
// installed lerd container while preserving each unit's current configuration.
-// Only changed units that are already running are restarted; inactive runtime
+// 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 {
- changed, err := podman.RebindInstalledQuadletsForLAN()
+ restart, err := podman.RebindInstalledQuadletsForLAN()
if err != nil {
return err
}
- if len(changed) == 0 {
+ if len(restart) == 0 {
return nil
}
if err := services.Mgr.DaemonReload(); err != nil {
return fmt.Errorf("daemon-reload: %w", err)
}
- for _, name := range changed {
+ var failures []error
+ for _, name := range restart {
status, _ := services.Mgr.UnitStatus(name)
if status != "active" && status != "activating" {
continue
@@ -234,10 +242,10 @@ func regenerateLANContainerQuadlets(progress LANProgressFunc) error {
progress("Restarting " + name)
}
if err := services.Mgr.Restart(name); err != nil {
- return fmt.Errorf("restarting %s: %w", name, err)
+ 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
diff --git a/internal/cli/lan_rebind_resilience_test.go b/internal/cli/lan_rebind_resilience_test.go
new file mode 100644
index 000000000..f5685bc4c
--- /dev/null
+++ b/internal/cli/lan_rebind_resilience_test.go
@@ -0,0 +1,106 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/geodro/lerd/internal/config"
+ "github.com/geodro/lerd/internal/podman"
+ "github.com/geodro/lerd/internal/services"
+)
+
+// rebindMgr reports every unit active and fails the restart of one of them, the
+// way a container with a broken image or an occupied port does.
+type rebindMgr struct {
+ services.ServiceManager
+ failing string
+ restarted []string
+}
+
+func (m *rebindMgr) DaemonReload() error { return nil }
+
+func (m *rebindMgr) UnitStatus(string) (string, error) { return "active", nil }
+
+func (m *rebindMgr) Restart(name string) error {
+ m.restarted = append(m.restarted, name)
+ if name == m.failing {
+ return fmt.Errorf("unit %s failed to start", name)
+ }
+ return nil
+}
+
+func writeServiceQuadlet(t *testing.T, name, ports string) {
+ t.Helper()
+ dir := config.QuadletDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ content := podman.CustomServiceQuadletMarker + "\n[Container]\nImage=docker.io/library/redis:7\nNetwork=lerd\n" + ports + "\n"
+ if err := os.WriteFile(filepath.Join(dir, name+".container"), []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+}
+
+// One container failing to restart must not strand the containers after it on
+// their old LAN bind while every status surface claims loopback-only.
+func TestRegenerateLANQuadletsRestartsEveryUnitDespiteFailure(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ cfg := &config.GlobalConfig{}
+ cfg.LAN.Exposed = true
+ if err := config.SaveGlobal(cfg); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+ // Alphabetically first fails, so a loop that aborts never reaches the rest.
+ writeServiceQuadlet(t, "lerd-aaa", "PublishPort=[::]:1111:1111")
+ writeServiceQuadlet(t, "lerd-mmm", "PublishPort=[::]:2222:2222")
+ writeServiceQuadlet(t, "lerd-zzz", "PublishPort=[::]:3333:3333")
+
+ mgr := &rebindMgr{failing: "lerd-aaa"}
+ prev := services.Mgr
+ services.Mgr = mgr
+ t.Cleanup(func() { services.Mgr = prev })
+
+ err := regenerateLANContainerQuadlets(nil)
+ if err == nil {
+ t.Fatal("a failed restart must be reported, not swallowed")
+ }
+ if !strings.Contains(err.Error(), "lerd-aaa") {
+ t.Errorf("error %q does not name the unit that failed", err)
+ }
+ for _, name := range []string{"lerd-mmm", "lerd-zzz"} {
+ if !slices.Contains(mgr.restarted, name) {
+ t.Errorf("%s was never restarted; restarted = %v", name, mgr.restarted)
+ }
+ }
+}
+
+// With the files already correct, the runtime probe is the only thing that can
+// notice a container still bound to the LAN, and it must drive a restart.
+func TestRegenerateLANQuadletsHealsRuntimeDrift(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ cfg := &config.GlobalConfig{}
+ if err := config.SaveGlobal(cfg); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+ writeServiceQuadlet(t, "lerd-redis", "PublishPort=127.0.0.1:6379:6379")
+
+ prevProbe := podman.ContainerPublishesLANFn
+ podman.ContainerPublishesLANFn = func(name string) (bool, bool) { return name == "lerd-redis", true }
+ t.Cleanup(func() { podman.ContainerPublishesLANFn = prevProbe })
+
+ mgr := &rebindMgr{}
+ prev := services.Mgr
+ services.Mgr = mgr
+ t.Cleanup(func() { services.Mgr = prev })
+
+ if err := regenerateLANContainerQuadlets(nil); err != nil {
+ t.Fatalf("regenerateLANContainerQuadlets: %v", err)
+ }
+ if !slices.Contains(mgr.restarted, "lerd-redis") {
+ t.Fatalf("stranded container was not restarted; restarted = %v", mgr.restarted)
+ }
+}
diff --git a/internal/podman/lan_drift_test.go b/internal/podman/lan_drift_test.go
new file mode 100644
index 000000000..3f717ebbf
--- /dev/null
+++ b/internal/podman/lan_drift_test.go
@@ -0,0 +1,107 @@
+package podman
+
+import (
+ "slices"
+ "testing"
+)
+
+// stubRuntimeBinds points the runtime probe at a fixed answer per container.
+func stubRuntimeBinds(t *testing.T, lanBound map[string]bool) {
+ t.Helper()
+ prev := ContainerPublishesLANFn
+ ContainerPublishesLANFn = func(name string) (bool, bool) {
+ bound, known := lanBound[name]
+ return bound, known
+ }
+ t.Cleanup(func() { ContainerPublishesLANFn = prev })
+}
+
+// A container left LAN-bound by a toggle that aborted part way is the whole
+// point of the runtime probe: the quadlet on disk already says loopback, so
+// file comparison alone reports nothing to do and the drift never heals.
+func TestRebindReportsRuntimeDriftWhenFileAlreadyCorrect(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, false, false)
+ writeLANQuadlet(t, "lerd-redis", true, "PublishPort=127.0.0.1:6379:6379\nPublishPort=[::1]:6379:6379")
+ stubRuntimeBinds(t, map[string]bool{"lerd-redis": true})
+
+ restart, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if !slices.Contains(restart, "lerd-redis") {
+ t.Fatalf("restart list %v omits the container still bound to the LAN", restart)
+ }
+}
+
+// The mirror case: policy says LAN, the file says LAN, but the container is
+// still running its old loopback bind.
+func TestRebindReportsRuntimeDriftWhenContainerStillLoopback(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, true, true)
+ writeLANQuadlet(t, "lerd-mysql", true, "PublishPort=[::]:3306:3306")
+ stubRuntimeBinds(t, map[string]bool{"lerd-mysql": false})
+
+ restart, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if !slices.Contains(restart, "lerd-mysql") {
+ t.Fatalf("restart list %v omits the container still bound to loopback", restart)
+ }
+}
+
+// A container whose runtime already matches the policy needs no restart, so a
+// repeated toggle stays quiet.
+func TestRebindStaysQuietWhenRuntimeMatches(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, false, false)
+ writeLANQuadlet(t, "lerd-redis", true, "PublishPort=127.0.0.1:6379:6379\nPublishPort=[::1]:6379:6379")
+ stubRuntimeBinds(t, map[string]bool{"lerd-redis": false})
+
+ restart, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if len(restart) != 0 {
+ t.Fatalf("restart list = %v, want empty", restart)
+ }
+}
+
+// Nothing running (or no podman at all) must not invent restarts.
+func TestRebindIgnoresUnknownRuntimeState(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ writeLANConfig(t, false, false)
+ writeLANQuadlet(t, "lerd-redis", true, "PublishPort=127.0.0.1:6379:6379\nPublishPort=[::1]:6379:6379")
+ stubRuntimeBinds(t, map[string]bool{})
+
+ restart, err := RebindInstalledQuadletsForLAN()
+ if err != nil {
+ t.Fatalf("RebindInstalledQuadletsForLAN: %v", err)
+ }
+ if len(restart) != 0 {
+ t.Fatalf("restart list = %v, want empty", restart)
+ }
+}
+
+func TestPortsPublishToLAN(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ ports string
+ want bool
+ }{
+ {"dual-stack loopback", "127.0.0.1:3306->3306/tcp, ::1:3306->3306/tcp, 33060/tcp", false},
+ {"ipv6 wildcard", ":::3306->3306/tcp, 33060/tcp", true},
+ {"ipv4 wildcard", "0.0.0.0:80->80/tcp", true},
+ {"mixed", "127.0.0.1:1025->1025/tcp, :::8025->8025/tcp", true},
+ {"unpublished only", "9000/tcp", false},
+ {"empty", "", false},
+ {"udp loopback", "127.0.0.1:5300->5300/udp", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := portsPublishToLAN(tc.ports); got != tc.want {
+ t.Errorf("portsPublishToLAN(%q) = %v, want %v", tc.ports, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/podman/quadlet.go b/internal/podman/quadlet.go
index 65770d1d9..a59f44057 100644
--- a/internal/podman/quadlet.go
+++ b/internal/podman/quadlet.go
@@ -124,9 +124,54 @@ func BindQuadletForLAN(name, content string, lanExposed, servicesExposed bool) s
return BindForLAN(content, exposed)
}
+// ContainerPublishesLANFn probes whether a running container currently
+// publishes any port beyond loopback. It reports (lanBound, known); known is
+// false when the container is absent, stopped, or podman can't be reached.
+// Swappable in tests.
+var ContainerPublishesLANFn = containerPublishesLAN
+
+func containerPublishesLAN(name string) (bool, bool) {
+ out, err := Run("ps", "--filter", "name=^"+name+"$", "--format", "{{.Ports}}")
+ if err != nil {
+ return false, false
+ }
+ trimmed := strings.TrimSpace(out)
+ if trimmed == "" {
+ return false, false
+ }
+ return portsPublishToLAN(trimmed), true
+}
+
+// portsPublishToLAN parses podman's port column ("127.0.0.1:3306->3306/tcp,
+// :::8025->8025/tcp, 9000/tcp") and reports whether any published binding
+// listens beyond loopback. Entries without "->" are container-only ports.
+func portsPublishToLAN(ports string) bool {
+ for _, entry := range strings.Split(ports, ",") {
+ entry = strings.TrimSpace(entry)
+ hostSide, _, found := strings.Cut(entry, "->")
+ if !found {
+ continue
+ }
+ colon := strings.LastIndex(hostSide, ":")
+ if colon < 0 {
+ // No host IP at all means podman published on every interface.
+ return true
+ }
+ ip := net.ParseIP(strings.Trim(hostSide[:colon], "[]"))
+ if ip == nil || !ip.IsLoopback() {
+ return true
+ }
+ }
+ return false
+}
+
// RebindInstalledQuadletsForLAN reapplies the current LAN policy to every
-// installed lerd container. It rewrites only changed units and preserves each
-// installed unit's image, ports, volumes, and custom settings.
+// installed lerd container, preserving each unit's image, ports, volumes and
+// custom settings. It returns the units that need restarting: those whose file
+// it rewrote, plus those whose running container still publishes on the wrong
+// side of the policy. That second group is what makes the operation heal
+// itself — a container left LAN-bound by an earlier toggle that failed part way
+// is picked up on the next run even though its file already reads correctly.
func RebindInstalledQuadletsForLAN() ([]string, error) {
lanExposed := false
servicesExposed := false
@@ -139,7 +184,7 @@ func RebindInstalledQuadletsForLAN() ([]string, error) {
return nil, err
}
- changed := make([]string, 0, len(paths))
+ restart := make([]string, 0, len(paths))
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
@@ -147,21 +192,50 @@ func RebindInstalledQuadletsForLAN() ([]string, error) {
}
name := strings.TrimSuffix(filepath.Base(path), ".container")
updated := PairIPv6Binds(BindQuadletForLAN(name, string(content), lanExposed, servicesExposed))
- if string(content) == updated {
+ if string(content) != updated {
+ config.GuardRealWrite(path)
+ if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
+ return nil, fmt.Errorf("rewriting %s: %w", filepath.Base(path), err)
+ }
+ if AfterQuadletWriteFn != nil {
+ if err := AfterQuadletWriteFn(name, updated); err != nil {
+ return nil, fmt.Errorf("syncing %s: %w", name, err)
+ }
+ }
+ restart = append(restart, name)
continue
}
- config.GuardRealWrite(path)
- if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
- return nil, fmt.Errorf("rewriting %s: %w", filepath.Base(path), err)
+ if lanBound, known := ContainerPublishesLANFn(name); known && lanBound != quadletWantsLAN(updated) {
+ restart = append(restart, name)
}
- if AfterQuadletWriteFn != nil {
- if err := AfterQuadletWriteFn(name, updated); err != nil {
- return nil, fmt.Errorf("syncing %s: %w", name, err)
+ }
+ return restart, nil
+}
+
+// quadletWantsLAN reports whether the given quadlet content publishes beyond
+// loopback, i.e. what the running container should look like.
+func quadletWantsLAN(content string) bool {
+ for _, line := range strings.Split(content, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if !strings.HasPrefix(trimmed, "PublishPort=") {
+ continue
+ }
+ value := strings.TrimPrefix(trimmed, "PublishPort=")
+ colon := strings.Index(value, ":")
+ if colon < 0 {
+ continue
+ }
+ ip := net.ParseIP(strings.Trim(value[:colon], "[]"))
+ if strings.HasPrefix(value, "[") {
+ if end := strings.Index(value, "]"); end > 0 {
+ ip = net.ParseIP(value[1:end])
}
}
- changed = append(changed, name)
+ if ip == nil || !ip.IsLoopback() {
+ return true
+ }
}
- return changed, nil
+ return false
}
// ListManagedServiceNames returns the service names (lerd- prefix and .container
From 381cf3ac1061b6bdac94ccdc131b272baa738e7a Mon Sep 17 00:00:00 2001
From: George Dumitrescu
Date: Fri, 31 Jul 2026 17:51:07 +0300
Subject: [PATCH 3/3] feat: put remote host actions behind an explicit setting
An authenticated remote session drives the dashboard, but the actions that reach the host itself stay local unless the user asks for them: raw .env reads, filesystem browsing, database drops, terminals, tooling replacement and shutting lerd down. The list guarding those routes is unchanged, and the gate now consults ui.remote_full_access rather than refusing outright, so an install that has not opted in answers a remote client exactly as before.
The opt-in lives on lerd remote-control full-access on/off/status and on a toggle in the Remote dashboard access card. Both refuse to run from a remote session, so a session can never widen its own authority, and remote-control off clears the setting along with the credentials instead of leaving it armed for whatever password is set next.
Authority is asked for by name now. hasHostActionAuthority covers the handlers that already sit behind the gate, hasDashboardControl covers the ones that have to hold up on their own, and isLoopbackRequest means loopback again rather than doubling as a stand-in for an authenticated session.
A loopback request carrying X-Forwarded-For, X-Forwarded-Host, X-Real-IP or Forwarded is treated as remote, since a reverse proxy relaying someone else's browser also connects from 127.0.0.1. The Host header is no longer part of that decision, because a browser on the machine may legitimately arrive as the hostname or an /etc/hosts alias, and turning those away leaves the local user with no way in and no credential that helps.
---
cmd/lerd/main.go | 1 +
docs/features/web-ui.md | 6 +-
docs/reference/commands.md | 11 +-
docs/usage/remote-development.md | 30 ++-
internal/cli/remote_control.go | 71 +++++
.../cli/remote_control_full_access_test.go | 113 ++++++++
internal/config/global.go | 11 +
internal/ui/cleanup.go | 2 +-
internal/ui/commands.go | 2 +-
internal/ui/dashproxy.go | 2 +-
internal/ui/devtools.go | 2 +-
internal/ui/dumps.go | 8 +-
internal/ui/editor.go | 2 +-
internal/ui/lan_status_test.go | 2 +
internal/ui/local_control_test.go | 81 ++++++
internal/ui/notify_target_http.go | 4 +-
internal/ui/openfolder.go | 2 +-
internal/ui/profiler.go | 4 +-
internal/ui/remote_control.go | 210 ++++++++++++---
internal/ui/remote_control_test.go | 75 +++---
internal/ui/remote_full_access_test.go | 247 ++++++++++++++++++
internal/ui/server.go | 2 +-
internal/ui/site_doctor.go | 2 +-
internal/ui/web/messages/de.json | 3 +
internal/ui/web/messages/en.json | 3 +
internal/ui/web/messages/es.json | 3 +
internal/ui/web/messages/fr.json | 3 +
internal/ui/web/messages/id.json | 3 +
internal/ui/web/messages/it.json | 3 +
internal/ui/web/messages/ja.json | 3 +
internal/ui/web/messages/nl.json | 3 +
internal/ui/web/messages/pl.json | 3 +
internal/ui/web/messages/pt.json | 3 +
internal/ui/web/messages/ro.json | 3 +
internal/ui/web/messages/tr.json | 3 +
internal/ui/web/messages/vi.json | 3 +
internal/ui/web/messages/zh.json | 3 +
.../ui/web/src/stores/remoteControl.test.ts | 74 ++++++
internal/ui/web/src/stores/remoteControl.ts | 53 +++-
.../ui/web/src/tabs/system/LerdDetail.svelte | 21 +-
40 files changed, 983 insertions(+), 97 deletions(-)
create mode 100644 internal/cli/remote_control_full_access_test.go
create mode 100644 internal/ui/local_control_test.go
create mode 100644 internal/ui/remote_full_access_test.go
create mode 100644 internal/ui/web/src/stores/remoteControl.test.ts
diff --git a/cmd/lerd/main.go b/cmd/lerd/main.go
index f483b379a..cd59b5535 100644
--- a/cmd/lerd/main.go
+++ b/cmd/lerd/main.go
@@ -246,6 +246,7 @@ func main() {
root.AddCommand(cli.NewRemoteControlOnCmd())
root.AddCommand(cli.NewRemoteControlOffCmd())
root.AddCommand(cli.NewRemoteControlStatusCmd())
+ root.AddCommand(cli.NewRemoteControlFullAccessCmd())
root.AddCommand(newWatchCmd())
root.AddCommand(newServeUICmd())
diff --git a/docs/features/web-ui.md b/docs/features/web-ui.md
index 38277910d..6cce56eab 100644
--- a/docs/features/web-ui.md
+++ b/docs/features/web-ui.md
@@ -114,7 +114,7 @@ Selecting a site opens the detail panel with:
- **Remove Worktree modal**: opens scoped to a single branch when its tab's × is clicked. Offers a *Discard uncommitted changes* (force) checkbox and, when isolated, an *Also drop database* checkbox. Runs `lerd worktree remove` and closes once the branch is gone
- **Live PHP-FPM log**: streams FPM output for the selected site; tab switches to queue/horizon/schedule/reverb logs when those workers are running
- **Coloured output**: every live log pane renders the ANSI colours the tool emitted, so Vite, Pest, artisan and composer read the same as they do in a terminal. Workers and UI-run commands are started with `FORCE_COLOR`, `CLICOLOR_FORCE` and a colour-capable `TERM` because they write to a pipe or a log file rather than a terminal and would otherwise strip their own colours; setting `NO_COLOR` in the environment lerd starts from turns all of that back off
-- **Follow in terminal**: the terminal icon in a log pane's header opens the host's terminal emulator tailing the same unit (`podman logs -f`, `tail -f`, or `journalctl -f` depending on the platform and the unit), so a long tail can outlive the browser tab. It uses `$TERMINAL` when set. Authenticated remote dashboards show the same action; the terminal opens on the host that runs Lerd.
+- **Follow in terminal**: the terminal icon in a log pane's header opens the host's terminal emulator tailing the same unit (`podman logs -f`, `tail -f`, or `journalctl -f` depending on the platform and the unit), so a long tail can outlive the browser tab. It uses `$TERMINAL` when set. Remote dashboards show the same action once `lerd remote-control full-access on` is set; the terminal opens on the host that runs Lerd.

@@ -140,7 +140,7 @@ Before you pick a service the detail panel shows a **services dashboard** instea
Selecting a service opens the detail panel with Start, Stop, and Restart controls, status, and the correct `.env` connection values with a one-click copy button. Restart is available for every built-in and custom service and wraps `podman restart` (clears the paused flag on success); the grouped per-site workers (Queues, Horizon, Schedules, Workers, Stripe, Reverb) remain start/stop only. A **Check for updates** action sits in the service's action menu (non-worker services only); it bypasses the cached availability lookup, re-fetches the registry tag list, and shows either an "Already up to date" hint or a "Update available: {tag}" banner that becomes the live Update button moments later. Database service detail panels (mysql, postgres, mongo, and any installed alternate like `mysql-5-7`) get a few extras:
-- **Databases tab**: the panel opens on it, listing what is actually inside the running engine as cards with their sizes. From a card you can create and drop a database, export it to a plain SQL dump or import one, copy a per-database connection string, and open it in an installed admin tool, with that database's snapshots to take, restore, delete or download on the same card. Cards link back to the site that owns the database, a `_testing` database folds into the card of the database it tests, and a worktree's isolated database is shown under its own branch domain. It requires dashboard-control authority, which authenticated remote sessions receive. See [Databases](../usage/database.md).
+- **Databases tab**: the panel opens on it, listing what is actually inside the running engine as cards with their sizes. From a card you can create and drop a database, export it to a plain SQL dump or import one, copy a per-database connection string, and open it in an installed admin tool, with that database's snapshots to take, restore, delete or download on the same card. Cards link back to the site that owns the database, a `_testing` database folds into the card of the database it tests, and a worktree's isolated database is shown under its own branch domain. It requires dashboard-control authority, which remote sessions receive only after `lerd remote-control full-access on`. See [Databases](../usage/database.md).
- **Suggestion banner**: a sky-blue tip offering to install the paired admin UI (phpMyAdmin / pgAdmin / Mongo Express) when it isn't installed yet. Dismissable per-preset; dismissal persists in `localStorage`.
- **Open admin button**: when the paired admin UI is installed, a button on the header opens its dashboard inline as a full-width iframe overlay and auto-starts the admin service if needed. When no admin UI is installed and the service is active, a fallback **Open connection URL** anchor hands the `mysql://` / `postgresql://` / `mongodb://` URL to your registered DB client (DBeaver, TablePlus, Compass, etc.).
- **Dashboard button**: for any service that exposes a dashboard URL (Mailpit, RustFS, Meilisearch, phpMyAdmin, etc.), a Dashboard button in the header opens it as an inline full-width iframe. The iframe overlay has its own header with the service URL, an **Open in new tab** escape hatch, and a close button. Clicking one of the main nav icons (Sites / Services / System) also closes the overlay.
@@ -170,4 +170,4 @@ The **Start** / **Stop** buttons in the System panel header start or stop all co
## Updates
-Shows the current version. When an update is available, the Lerd entry exposes an **Open terminal & update** button that launches the host's terminal emulator running `lerd update`. An authenticated remote dashboard shows the same action; the terminal opens on the host that runs Lerd.
+Shows the current version. When an update is available, the Lerd entry exposes an **Open terminal & update** button that launches the host's terminal emulator running `lerd update`. A remote dashboard shows the same action once `lerd remote-control full-access on` is set; the terminal opens on the host that runs Lerd.
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index df1c78be4..f0d9bf619 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -119,9 +119,14 @@ The proxy runs inside the lerd daemon (`lerd-ui`), no external tool needed and n
| `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 |
-
-The dashboard **System** tab and terminal UI expose the same two independent
-settings. Authenticated remote dashboard sessions receive the same controls.
+| `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.
diff --git a/docs/usage/remote-development.md b/docs/usage/remote-development.md
index 344685731..fed233948 100644
--- a/docs/usage/remote-development.md
+++ b/docs/usage/remote-development.md
@@ -330,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. An authenticated remote session receives the same dashboard and controls as a local session. Actions run on the host that runs Lerd. 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:
@@ -357,6 +373,18 @@ Once the dashboard is exposed and credentials are set, the **Remote dashboard ac
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.
diff --git a/internal/cli/remote_control.go b/internal/cli/remote_control.go
index d997cd813..ee34860ed 100644
--- a/internal/cli/remote_control.go
+++ b/internal/cli/remote_control.go
@@ -42,6 +42,7 @@ flag — it has its own token + IP + brute-force gate.`,
cmd.AddCommand(newRemoteControlOnCmd())
cmd.AddCommand(newRemoteControlOffCmd())
cmd.AddCommand(newRemoteControlStatusCmd())
+ cmd.AddCommand(newRemoteControlFullAccessCmd())
return cmd
}
@@ -145,6 +146,7 @@ the password — you cannot lock yourself out of your own machine.`,
}
cfg.UI.Username = ""
cfg.UI.PasswordHash = ""
+ cfg.UI.RemoteFullAccess = false
if err := config.SaveGlobal(cfg); err != nil {
return fmt.Errorf("saving config: %w", err)
}
@@ -155,6 +157,70 @@ the password — you cannot lock yourself out of your own machine.`,
}
}
+// NewRemoteControlFullAccessCmd returns the `lerd remote-control:full-access`
+// colon alias.
+func NewRemoteControlFullAccessCmd() *cobra.Command {
+ cmd := newRemoteControlFullAccessCmd()
+ cmd.Use = "remote-control:full-access [on|off|status]"
+ cmd.Hidden = true
+ return cmd
+}
+
+func newRemoteControlFullAccessCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "full-access [on|off|status]",
+ Short: "Control whether remote sessions may run host actions",
+ Long: `A remote client with valid credentials can drive the dashboard, but the
+actions that reach the host itself stay local-only by default: reading a
+site's raw .env, browsing the filesystem, dropping or exporting databases,
+opening a terminal, and running commands.
+
+Run 'lerd remote-control full-access on' to let authenticated remote
+sessions use them too. The setting never replaces authentication, and only
+the local dashboard or a local shell can change it.`,
+ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
+ ValidArgs: []string{"on", "off", "status"},
+ RunE: func(_ *cobra.Command, args []string) error {
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ return fmt.Errorf("loading config: %w", err)
+ }
+
+ if args[0] == "status" {
+ feedback.Begin()
+ switch {
+ case cfg.UI.RemoteFullAccess && cfg.UI.PasswordHash != "":
+ feedback.Done("remote sessions may run host actions")
+ case cfg.UI.RemoteFullAccess:
+ feedback.Line("remote host actions are enabled but inactive until `lerd remote-control on`")
+ default:
+ feedback.Line("remote host actions are off; they stay local-only")
+ }
+ return nil
+ }
+
+ enabled := args[0] == "on"
+ if enabled && cfg.UI.PasswordHash == "" {
+ return fmt.Errorf("dashboard credentials are not configured — run `lerd remote-control on` first")
+ }
+ cfg.UI.RemoteFullAccess = enabled
+ if err := config.SaveGlobal(cfg); err != nil {
+ return fmt.Errorf("saving config: %w", err)
+ }
+
+ feedback.Begin()
+ if enabled {
+ feedback.Done("remote host actions " + feedback.Val("enabled"))
+ feedback.Note("anyone with the dashboard password can now read site .env files, browse the filesystem, drop databases and run commands on this machine")
+ feedback.Note("use only on a trusted network, and rotate the password with `lerd remote-control on` if it has ever been shared")
+ } else {
+ feedback.Done("remote host actions " + feedback.Val("local-only"))
+ }
+ return nil
+ },
+ }
+}
+
func newRemoteControlStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
@@ -173,6 +239,11 @@ func newRemoteControlStatusCmd() *cobra.Command {
}
feedback.Line("remote dashboard access: " + feedback.Green("enabled") + " (user: " + cfg.UI.Username + ")")
feedback.Note("LAN clients must present HTTP Basic auth; loopback bypasses it")
+ if cfg.UI.RemoteFullAccess {
+ feedback.Note("host actions: allowed remotely (`lerd remote-control full-access off` to restrict)")
+ } else {
+ feedback.Note("host actions: local-only (`lerd remote-control full-access on` to allow)")
+ }
feedback.Note("disable with: lerd remote-control off")
return nil
},
diff --git a/internal/cli/remote_control_full_access_test.go b/internal/cli/remote_control_full_access_test.go
new file mode 100644
index 000000000..0a052b0d3
--- /dev/null
+++ b/internal/cli/remote_control_full_access_test.go
@@ -0,0 +1,113 @@
+package cli
+
+import (
+ "testing"
+
+ "github.com/geodro/lerd/internal/config"
+)
+
+// withCredentials writes a config carrying a dashboard password so the
+// full-access command has something to widen.
+func withCredentials(t *testing.T) {
+ t.Helper()
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ cfg.UI.Username = "alice"
+ cfg.UI.PasswordHash = "$2a$04$abcdefghijklmnopqrstuv"
+ if err := config.SaveGlobal(cfg); err != nil {
+ t.Fatalf("SaveGlobal: %v", err)
+ }
+}
+
+func TestFullAccessCommandPersistsExplicitOptIn(t *testing.T) {
+ withCredentials(t)
+
+ cmd := newRemoteControlFullAccessCmd()
+ cmd.SetArgs([]string{"on"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("full-access on: %v", err)
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal after on: %v", err)
+ }
+ if !cfg.UI.RemoteFullAccess {
+ t.Fatal("full-access on did not persist ui.remote_full_access")
+ }
+
+ cmd = newRemoteControlFullAccessCmd()
+ cmd.SetArgs([]string{"off"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("full-access off: %v", err)
+ }
+ cfg, err = config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal after off: %v", err)
+ }
+ if cfg.UI.RemoteFullAccess {
+ t.Fatal("full-access off did not clear ui.remote_full_access")
+ }
+}
+
+func TestFullAccessCommandRequiresCredentials(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ cmd := newRemoteControlFullAccessCmd()
+ cmd.SetArgs([]string{"on"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("full-access on succeeded without dashboard credentials")
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if cfg.UI.RemoteFullAccess {
+ t.Fatal("failed full-access on still changed ui.remote_full_access")
+ }
+}
+
+func TestFullAccessCommandRejectsUnknownState(t *testing.T) {
+ withCredentials(t)
+
+ cmd := newRemoteControlFullAccessCmd()
+ cmd.SetArgs([]string{"maybe"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("full-access accepted an unknown state")
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if cfg.UI.RemoteFullAccess {
+ t.Fatal("invalid state changed ui.remote_full_access")
+ }
+}
+
+// Turning remote access off entirely must not leave the widened authority
+// behind, waiting to apply to whatever password is set next.
+func TestRemoteControlOffClearsFullAccess(t *testing.T) {
+ withCredentials(t)
+
+ cmd := newRemoteControlFullAccessCmd()
+ cmd.SetArgs([]string{"on"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("full-access on: %v", err)
+ }
+
+ off := newRemoteControlOffCmd()
+ off.SetArgs(nil)
+ if err := off.Execute(); err != nil {
+ t.Fatalf("remote-control off: %v", err)
+ }
+
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if cfg.UI.RemoteFullAccess {
+ t.Fatal("remote-control off left ui.remote_full_access set")
+ }
+}
diff --git a/internal/config/global.go b/internal/config/global.go
index 992effbfc..223b159c6 100644
--- a/internal/config/global.go
+++ b/internal/config/global.go
@@ -195,6 +195,17 @@ type GlobalConfig struct {
// (127.0.0.1, ::1) always bypasses both checks.
Username string `yaml:"username,omitempty" mapstructure:"username"`
PasswordHash string `yaml:"password_hash,omitempty" mapstructure:"password_hash"`
+
+ // RemoteFullAccess opts authenticated remote sessions into the host
+ // actions that are otherwise reserved for the local dashboard: raw
+ // .env reads, filesystem browsing, database drops, terminals and
+ // command execution. Off by default, so a leaked or guessed password
+ // alone never reaches them. It widens which routes an authenticated
+ // session may use; it never substitutes for authentication.
+ //
+ // Toggled via `lerd remote-control full-access on/off`, which only
+ // the local dashboard or a local shell can do.
+ RemoteFullAccess bool `yaml:"remote_full_access,omitempty" mapstructure:"remote_full_access"`
} `yaml:"ui,omitempty" mapstructure:"ui"`
Workers struct {
// ExecMode controls how framework workers (queue, schedule, horizon,
diff --git a/internal/ui/cleanup.go b/internal/ui/cleanup.go
index 0bf332d1c..27c0a9399 100644
--- a/internal/ui/cleanup.go
+++ b/internal/ui/cleanup.go
@@ -100,7 +100,7 @@ func handleDisk(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
writeJSON(w, cachedDisk())
case http.MethodPost:
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/commands.go b/internal/ui/commands.go
index 1da34ca94..d76576b71 100644
--- a/internal/ui/commands.go
+++ b/internal/ui/commands.go
@@ -78,7 +78,7 @@ func commandRoute(w http.ResponseWriter, r *http.Request, domain string, rest []
case len(rest) == 3 && rest[2] == "run" && r.Method == http.MethodPost:
// Running a command requires dashboard-control authority because it
// executes arbitrary shell code as the lerd-ui user.
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return true
}
diff --git a/internal/ui/dashproxy.go b/internal/ui/dashproxy.go
index 4fd38e703..ac6c7ad5f 100644
--- a/internal/ui/dashproxy.go
+++ b/internal/ui/dashproxy.go
@@ -261,7 +261,7 @@ func resolveDashboardURL(svc *config.CustomService, services map[string]config.S
// handleDashProxy serves a bundled service dashboard same-origin under
// /_svc//. It requires dashboard-control authority.
func handleDashProxy(w http.ResponseWriter, r *http.Request) {
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/devtools.go b/internal/ui/devtools.go
index c9fbfdae7..80416489b 100644
--- a/internal/ui/devtools.go
+++ b/internal/ui/devtools.go
@@ -43,7 +43,7 @@ func handleDevtoolsWorkers(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/dumps.go b/internal/ui/dumps.go
index 3a15d0272..8c90a7480 100644
--- a/internal/ui/dumps.go
+++ b/internal/ui/dumps.go
@@ -229,7 +229,7 @@ func handleDumpsClear(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -248,7 +248,7 @@ func handleDumpsPassthrough(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -276,7 +276,7 @@ func handleDumpsNotifyChanged(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -291,7 +291,7 @@ func handleDumpsToggle(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/editor.go b/internal/ui/editor.go
index a37895754..bc265d199 100644
--- a/internal/ui/editor.go
+++ b/internal/ui/editor.go
@@ -22,7 +22,7 @@ func handleOpenEditor(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/lan_status_test.go b/internal/ui/lan_status_test.go
index 72e4c2110..27f150f47 100644
--- a/internal/ui/lan_status_test.go
+++ b/internal/ui/lan_status_test.go
@@ -102,6 +102,7 @@ func TestLANStatusRejectsUnauthenticatedLoopbackReverseProxy(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/lan/status", strings.NewReader(`{"action":"services_on"}`))
req.RemoteAddr = "127.0.0.1:54321"
req.Host = "robotbox.example.net"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
rec := httptest.NewRecorder()
handleLANStatus(rec, req)
@@ -114,6 +115,7 @@ func TestAccessModeRejectsUnauthenticatedLoopbackReverseProxy(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/access-mode", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Host = "robotbox.example.net"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
rec := httptest.NewRecorder()
handleAccessMode(rec, req)
diff --git a/internal/ui/local_control_test.go b/internal/ui/local_control_test.go
new file mode 100644
index 000000000..9e73bba8b
--- /dev/null
+++ b/internal/ui/local_control_test.go
@@ -0,0 +1,81 @@
+package ui
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// A browser on the lerd host may reach the dashboard by a name that is not
+// "localhost": Debian and Ubuntu map the machine's hostname to 127.0.1.1, and
+// /etc/hosts aliases are common. Those requests must keep working, or the local
+// user is locked out of their own dashboard with no credential that helps.
+func TestLocalControlAcceptsLoopbackHostnames(t *testing.T) {
+ setupConfigDirRaw(t, "", "", false) // LAN off, no credentials: only local works
+
+ for _, tc := range []struct{ name, peer, host string }{
+ {"localhost", "127.0.0.1:54321", "localhost:7073"},
+ {"loopback ip", "127.0.0.1:54321", "127.0.0.1:7073"},
+ {"nginx vhost", "127.0.0.1:54321", "lerd.localhost"},
+ {"machine hostname", "127.0.1.1:54321", "workstation:7073"},
+ {"hosts alias", "127.0.0.1:54321", "dev.internal"},
+ {"ipv6 loopback", "[::1]:54321", "[::1]:7073"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ next := &nextHandler{}
+ req := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
+ req.RemoteAddr = tc.peer
+ req.Host = tc.host
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(next).ServeHTTP(rec, req)
+
+ if !next.called {
+ t.Errorf("local request with Host %q was blocked (status %d)", tc.host, rec.Code)
+ }
+ })
+ }
+}
+
+// A reverse proxy relaying a remote browser also connects from 127.0.0.1. The
+// forwarding headers it adds are what separates it from a local browser.
+func TestLocalControlRejectsForwardedRequests(t *testing.T) {
+ for _, header := range proxyHeaders {
+ t.Run(header, func(t *testing.T) {
+ setupConfigDirRaw(t, "", "", true) // LAN exposed, no credentials
+
+ next := &nextHandler{}
+ req := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "dashboard.example.ts.net"
+ req.Header.Set(header, "203.0.113.7")
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(next).ServeHTTP(rec, req)
+
+ if next.called {
+ t.Errorf("%s did not stop a proxied request from bypassing authentication", header)
+ }
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ })
+ }
+}
+
+// The unix socket carries no peer address and no forwarding headers; it is
+// reached only by host processes, so it stays authoritative.
+func TestLocalControlAcceptsUnixSocketWithForeignHost(t *testing.T) {
+ setupConfigDirRaw(t, "", "", false)
+
+ next := &nextHandler{}
+ req := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
+ req.RemoteAddr = ""
+ req.Host = "lerd.localhost"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyUnixSocket{}, true))
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(next).ServeHTTP(rec, req)
+
+ if !next.called {
+ t.Fatalf("unix-socket request was blocked (status %d)", rec.Code)
+ }
+}
diff --git a/internal/ui/notify_target_http.go b/internal/ui/notify_target_http.go
index 5f4e8d497..61aec4da3 100644
--- a/internal/ui/notify_target_http.go
+++ b/internal/ui/notify_target_http.go
@@ -38,7 +38,7 @@ func handleNotifyTarget(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.LoadGlobal()
writeJSON(w, newNotifyTargetResponse(cfg))
case http.MethodPost:
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -76,7 +76,7 @@ func handleNotifyKinds(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/openfolder.go b/internal/ui/openfolder.go
index 36a0c0016..cf48c76cf 100644
--- a/internal/ui/openfolder.go
+++ b/internal/ui/openfolder.go
@@ -19,7 +19,7 @@ func handleOpenFolder(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/profiler.go b/internal/ui/profiler.go
index ea42c71a2..69cd600a8 100644
--- a/internal/ui/profiler.go
+++ b/internal/ui/profiler.go
@@ -20,7 +20,7 @@ func handleProfilerToggle(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -64,7 +64,7 @@ func handleProfilerClear(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/remote_control.go b/internal/ui/remote_control.go
index 92b841439..5a9c4f9d4 100644
--- a/internal/ui/remote_control.go
+++ b/internal/ui/remote_control.go
@@ -18,6 +18,82 @@ import (
type ctxKeyRemoteDashboard struct{}
+// loopbackOnlyRoutes are dashboard endpoints that perform actions too
+// destructive or sensitive to hand to a remote (LAN) client on the strength
+// of a password alone: shutting lerd down entirely, opening a terminal on
+// the host, linking arbitrary host filesystem paths as new sites. The local
+// user can still use them as normal, and a remote session reaches them only
+// after `lerd remote-control full-access on`.
+var loopbackOnlyRoutes = []string{
+ "/api/lerd/stop", // shuts down all lerd containers
+ "/api/lerd/quit", // exits the dashboard process
+ "/api/lerd/update-terminal", // spawns a terminal emulator on the host
+ "/api/logs/terminal", // spawns a terminal emulator on the host
+ "/api/sites/link", // links arbitrary host filesystem paths
+ "/api/browse", // browses host filesystem
+ "/api/push/test", // fires notifications onto subscribed devices
+}
+
+// loopbackOnlyRoutePrefixes are endpoint subtrees restricted in full, so a
+// new subresource cannot escape by failing to be listed. Databases read out,
+// drop and overwrite the data the "/env" gate already protects.
+var loopbackOnlyRoutePrefixes = []string{
+ "/api/databases",
+ "/api/entities",
+ // Replaces executables on the host's PATH, so it stays with the terminal
+ // and link routes rather than behind Basic auth alone.
+ "/api/tools",
+}
+
+// loopbackOnlySiteSubactions are the per-site actions (under
+// /api/sites/{domain}/) whose entire subtree is restricted. A subaction
+// "/env" gates /api/sites/{d}/env and every nested route under it (e.g.
+// /env/files, /env/backups, /env/backups/, /env/restore), so adding a
+// new subresource cannot accidentally escape the gate by failing to be
+// re-listed here.
+var loopbackOnlySiteSubactions = []string{
+ "/terminal", // opens an interactive shell on the host
+ "/env", // raw .env content + backups + restore (APP_KEY, DB creds, tokens)
+}
+
+// isLoopbackOnlyPath reports whether the given URL path is in either the
+// exact-match list or matches a per-site action whose entire subtree is
+// restricted.
+func isLoopbackOnlyPath(path string) bool {
+ for _, p := range loopbackOnlyRoutes {
+ if path == p {
+ return true
+ }
+ }
+ for _, p := range loopbackOnlyRoutePrefixes {
+ if path == p || strings.HasPrefix(path, p+"/") {
+ return true
+ }
+ }
+ if !strings.HasPrefix(path, "/api/sites/") {
+ return false
+ }
+ rest := strings.TrimPrefix(path, "/api/sites/")
+ slash := strings.Index(rest, "/")
+ if slash < 0 {
+ return false
+ }
+ after := rest[slash:]
+ for _, action := range loopbackOnlySiteSubactions {
+ if after == action || strings.HasPrefix(after, action+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+// remoteFullAccessEnabled reports whether authenticated remote sessions have
+// been opted into host actions.
+func remoteFullAccessEnabled() bool {
+ cfg, _ := config.LoadGlobal()
+ return cfg != nil && cfg.UI.RemoteFullAccess
+}
+
// fromHost reports whether r's source IP belongs to one of the host's
// own interfaces. The mailpit container reaches the dashboard via
// host.containers.internal, which pasta (Linux) and gvproxy / vmnet
@@ -138,8 +214,11 @@ func passesCSRF(r *http.Request) bool {
//
// Direct local dashboard requests bypass both checks. OPTIONS preflight
// passes through because it has no Authorization header. /api/remote-setup
-// has its own token and IP gate. An authenticated remote dashboard receives
-// the same controls as the local dashboard.
+// has its own token and IP gate.
+//
+// Beyond authentication, the loopbackOnlyRoutes list stays closed to remote
+// clients unless cfg.UI.RemoteFullAccess is set. The local user keeps those
+// routes either way.
func withRemoteControlGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. CORS preflight: pass through. Browsers don't include the
@@ -194,6 +273,15 @@ func withRemoteControlGate(next http.Handler) http.Handler {
// remote control or LAN exposure.
cfg, _ := config.LoadGlobal()
+ // 4pre. Host-action routes stay closed to remote clients unless the
+ // user has explicitly opted in. This is checked before credentials
+ // so an unopted install answers the same way whatever is guessed.
+ if isLoopbackOnlyPath(r.URL.Path) && (cfg == nil || !cfg.UI.RemoteFullAccess) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "Forbidden — this action is only available from the lerd host. Run `lerd remote-control full-access on` to allow it remotely.", http.StatusForbidden)
+ return
+ }
+
// 4a. LAN exposure is the top-level gate. If lan:expose is off,
// LAN clients are denied regardless of whether credentials are
// set — this prevents stale credentials from a previous expose
@@ -442,8 +530,9 @@ func handleRemoteControl(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, map[string]any{
- "enabled": cfg.UI.PasswordHash != "",
- "username": cfg.UI.Username,
+ "enabled": cfg.UI.PasswordHash != "",
+ "username": cfg.UI.Username,
+ "full_access": cfg.UI.RemoteFullAccess,
})
return
@@ -452,6 +541,7 @@ func handleRemoteControl(w http.ResponseWriter, r *http.Request) {
Action string `json:"action"`
Username string `json:"username"`
Password string `json:"password"`
+ Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
@@ -496,15 +586,35 @@ func handleRemoteControl(w http.ResponseWriter, r *http.Request) {
case "disable":
cfg.UI.Username = ""
cfg.UI.PasswordHash = ""
+ cfg.UI.RemoteFullAccess = false
+ if err := config.SaveGlobal(cfg); err != nil {
+ http.Error(w, "saving config: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "enabled": false, "full_access": false})
+ return
+
+ case "full-access":
+ // Only the local dashboard may widen remote authority, so a
+ // remote session can never grant itself host actions.
+ if !isLocalControlRequest(r) {
+ http.Error(w, "Forbidden — remote full access can only be changed from the lerd host.", http.StatusForbidden)
+ return
+ }
+ if body.Enabled && cfg.UI.PasswordHash == "" {
+ http.Error(w, "dashboard credentials are not configured — run `lerd remote-control on` first", http.StatusBadRequest)
+ return
+ }
+ cfg.UI.RemoteFullAccess = body.Enabled
if err := config.SaveGlobal(cfg); err != nil {
http.Error(w, "saving config: "+err.Error(), http.StatusInternalServerError)
return
}
- writeJSON(w, map[string]any{"ok": true, "enabled": false})
+ writeJSON(w, map[string]any{"ok": true, "full_access": body.Enabled})
return
default:
- http.Error(w, "unknown action — expected 'enable' or 'disable'", http.StatusBadRequest)
+ http.Error(w, "unknown action — expected 'enable', 'disable' or 'full-access'", http.StatusBadRequest)
return
}
@@ -523,7 +633,7 @@ func handleRemoteSetupGenerate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "Forbidden — dashboard authentication is required to generate setup codes.", http.StatusForbidden)
return
}
@@ -551,12 +661,31 @@ func handleRemoteSetupGenerate(w http.ResponseWriter, r *http.Request) {
})
}
+// proxyHeaders are the headers a reverse proxy adds when it forwards a request
+// on someone else's behalf. Their presence is what distinguishes a proxied
+// request from a browser on the machine itself, since both arrive from 127.0.0.1.
+var proxyHeaders = []string{"X-Forwarded-For", "X-Forwarded-Host", "X-Real-Ip", "Forwarded"}
+
+// forwardedByProxy reports whether r carries evidence of having been relayed.
+func forwardedByProxy(r *http.Request) bool {
+ for _, h := range proxyHeaders {
+ if r.Header.Get(h) != "" {
+ return true
+ }
+ }
+ return false
+}
+
// isLocalControlRequest reports whether a request may control the lerd host.
// Unix-socket requests and requests carrying the private nginx trust token are
-// authoritative. A direct TCP request must have both a loopback peer and a
-// loopback or RFC-reserved .localhost Host. Requiring both rejects reverse
-// proxies, such as Tailscale Serve, that connect from 127.0.0.1 on behalf of a
-// remote browser.
+// authoritative. A direct TCP request qualifies when its peer is loopback and
+// it carries no forwarding headers, which rejects reverse proxies such as
+// Tailscale Serve that connect from 127.0.0.1 for a remote browser.
+//
+// The Host header is deliberately not part of this: a local browser may reach
+// the dashboard by the machine's own hostname (Debian and Ubuntu map it to
+// 127.0.1.1) or any /etc/hosts alias, and locking those out would leave the
+// local user with no way in.
func hasValidTrustToken(r *http.Request) bool {
claimed := r.Header.Get("X-Lerd-Trust")
@@ -575,39 +704,54 @@ func isLocalControlRequest(r *http.Request) bool {
if hasValidTrustToken(r) {
return true
}
+ if forwardedByProxy(r) {
+ return false
+ }
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
ip := net.ParseIP(peer)
- if ip == nil || !ip.IsLoopback() {
- return false
- }
- host := r.Host
- if parsed, _, err := net.SplitHostPort(r.Host); err == nil {
- host = parsed
- }
- host = strings.Trim(strings.ToLower(host), "[]")
- if host == "localhost" || strings.HasSuffix(host, ".localhost") {
- return true
- }
- hostIP := net.ParseIP(host)
- return hostIP != nil && hostIP.IsLoopback()
+ return ip != nil && ip.IsLoopback()
+}
+
+// remoteSessionMayActOnHost reports whether r is an authenticated remote
+// dashboard session that the user has opted into host actions.
+func remoteSessionMayActOnHost(r *http.Request) bool {
+ authenticated, _ := r.Context().Value(ctxKeyRemoteDashboard{}).(bool)
+ return authenticated && remoteFullAccessEnabled()
+}
+
+// hasHostActionAuthority reports whether r may perform an action that reaches
+// the host itself: executing commands, reading raw .env content, touching the
+// filesystem, deleting captured data. The local dashboard always may; a remote
+// session only after `lerd remote-control full-access on`. The middleware has
+// already applied the stricter local check to anything that reaches a handler,
+// so the loopback test here is the peer one.
+func hasHostActionAuthority(r *http.Request) bool {
+ return isLoopbackRequest(r) || remoteSessionMayActOnHost(r)
}
+// hasDashboardControl is hasHostActionAuthority for the handlers that must
+// hold up on their own, without the middleware in front: it rejects a reverse
+// proxy connecting from 127.0.0.1 on behalf of a remote browser.
func hasDashboardControl(r *http.Request) bool {
- if authenticated, _ := r.Context().Value(ctxKeyRemoteDashboard{}).(bool); authenticated {
- return true
- }
- return isLocalControlRequest(r)
+ return isLocalControlRequest(r) || remoteSessionMayActOnHost(r)
}
-// isLoopbackRequest gates handlers that are also called directly in tests.
-// Authenticated dashboard requests receive the same authority as loopback.
+// isLoopbackRequest reports whether r originates from the local host. Three
+// paths qualify:
+//
+// 1. The connection arrived over the unix socket listener. Only host
+// processes with filesystem access to the socket can connect, so this is
+// at least as trusted as TCP loopback. The lerd.localhost nginx vhost
+// reaches lerd-ui via this path.
+// 2. The TCP peer is a loopback IP (127.x, ::1). This catches direct visits
+// to http://localhost:7073 / http://127.0.0.1:7073.
+// 3. The request carries an X-Lerd-Trust header whose value matches the
+// per-install token. Kept for backward compatibility with old vhosts
+// that may still inject the header; new installs use the unix socket.
func isLoopbackRequest(r *http.Request) bool {
- if authenticated, _ := r.Context().Value(ctxKeyRemoteDashboard{}).(bool); authenticated {
- return true
- }
if v, _ := r.Context().Value(ctxKeyUnixSocket{}).(bool); v {
return true
}
diff --git a/internal/ui/remote_control_test.go b/internal/ui/remote_control_test.go
index 9a9a647e3..c132e572e 100644
--- a/internal/ui/remote_control_test.go
+++ b/internal/ui/remote_control_test.go
@@ -29,6 +29,19 @@ func setupConfigDir(t *testing.T, username, plainPassword string) {
}
func setupConfigDirRaw(t *testing.T, username, plainPassword string, lanExposed bool) {
+ t.Helper()
+ setupConfigDirWith(t, username, plainPassword, lanExposed, false)
+}
+
+// setupConfigDirFullAccess is setupConfigDir with ui.remote_full_access set,
+// for the tests that exercise the host-action opt-in. LAN exposure is on so
+// the remote requests reach the authentication step.
+func setupConfigDirFullAccess(t *testing.T, username, plainPassword string, fullAccess bool) {
+ t.Helper()
+ setupConfigDirWith(t, username, plainPassword, true, fullAccess)
+}
+
+func setupConfigDirWith(t *testing.T, username, plainPassword string, lanExposed, fullAccess bool) {
t.Helper()
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
@@ -37,15 +50,20 @@ func setupConfigDirRaw(t *testing.T, username, plainPassword string, lanExposed
if lanExposed {
cfg["lan"] = map[string]any{"exposed": true}
}
+ ui := map[string]any{}
if username != "" || plainPassword != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
- cfg["ui"] = map[string]any{
- "username": username,
- "password_hash": string(hash),
- }
+ ui["username"] = username
+ ui["password_hash"] = string(hash)
+ }
+ if fullAccess {
+ ui["remote_full_access"] = true
+ }
+ if len(ui) > 0 {
+ cfg["ui"] = ui
}
if len(cfg) == 0 {
return
@@ -104,6 +122,7 @@ func TestRemoteControlGateReverseProxyDoesNotBypassAuthentication(t *testing.T)
req := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Host = "robotbox.example.net"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
@@ -116,12 +135,13 @@ func TestRemoteControlGateReverseProxyDoesNotBypassAuthentication(t *testing.T)
}
func TestRemoteControlGateAuthenticatedReverseProxyReceivesDashboardControl(t *testing.T) {
- setupConfigDir(t, "alice", "s3cret")
+ setupConfigDirFullAccess(t, "alice", "s3cret", true)
gate := withRemoteControlGate(http.HandlerFunc(handleAccessMode))
req := httptest.NewRequest(http.MethodGet, "/api/access-mode", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Host = "robotbox.example.net"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
req.SetBasicAuth("alice", "s3cret")
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, req)
@@ -138,12 +158,13 @@ func TestRemoteControlGateAuthenticatedReverseProxyReceivesDashboardControl(t *t
}
func TestRemoteControlGateAuthenticatedDashboardCanMutateLANSettings(t *testing.T) {
- setupConfigDir(t, "alice", "s3cret")
+ setupConfigDirFullAccess(t, "alice", "s3cret", true)
gate := withRemoteControlGate(http.HandlerFunc(handleLANStatus))
req := httptest.NewRequest(http.MethodPost, "/api/lan/status", http.NoBody)
req.RemoteAddr = "192.168.1.42:54321"
req.Host = "robotbox.example.net"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
req.SetBasicAuth("alice", "s3cret")
req.Header.Set("X-Lerd-CSRF", "1")
rec := httptest.NewRecorder()
@@ -262,19 +283,15 @@ func TestRemoteControlGate_sessionCookie(t *testing.T) {
}
t.Run("cookie authenticates without Basic header", func(t *testing.T) {
- authorized := false
- next2 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- authorized = hasDashboardControl(r) && isLoopbackRequest(r)
- w.WriteHeader(http.StatusOK)
- })
+ next2 := &nextHandler{}
gate2 := withRemoteControlGate(next2)
req2 := httptest.NewRequest(http.MethodGet, "/api/sites", nil)
req2.RemoteAddr = "192.168.1.42:54321"
req2.AddCookie(session)
rec2 := httptest.NewRecorder()
gate2.ServeHTTP(rec2, req2)
- if !authorized || rec2.Code != http.StatusOK {
- t.Errorf("session cookie did not grant dashboard-control authority, status=%d", rec2.Code)
+ if !next2.called || rec2.Code != http.StatusOK {
+ t.Errorf("session cookie did not authenticate, status=%d", rec2.Code)
}
})
@@ -354,43 +371,31 @@ func TestRemoteControlGate_remoteSetupBypassesEvenWhenDisabled(t *testing.T) {
}
}
-func TestRemoteControlGateAuthenticatedDashboardCanUseHostControlRoutes(t *testing.T) {
+// Ordinary dashboard routes are the ones a remote session is meant to drive.
+// The host-action subset is covered in remote_full_access_test.go.
+func TestRemoteControlGateAuthenticatedDashboardUsesOrdinaryRoutes(t *testing.T) {
setupConfigDir(t, "alice", "s3cret")
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
- if !hasDashboardControl(r) {
- t.Error("authenticated dashboard did not receive dashboard-control authority")
- }
- if !isLoopbackRequest(r) {
- t.Error("authenticated dashboard did not pass handler-level authority checks")
- }
w.WriteHeader(http.StatusOK)
})
gate := withRemoteControlGate(next)
for _, path := range []string{
- "/api/lerd/stop",
- "/api/lerd/update-terminal",
- "/api/logs/terminal",
- "/api/sites/link",
- "/api/sites/myapp.test/terminal",
- "/api/sites/myapp.test/env",
- "/api/browse",
- "/api/push/test",
- "/api/databases",
- "/api/databases/mysql/drop",
- "/api/databases/mysql/export",
- "/api/databases/postgres/snapshots/nightly",
- "/api/remote-setup/generate",
- "/api/disk",
- "/api/open-editor",
+ "/api/lerd/start",
+ "/api/sites/myapp.test/secure",
+ "/api/sites/myapp.test/restart",
+ "/api/services/mysql/restart",
+ "/api/dumps/toggle",
} {
t.Run(path, func(t *testing.T) {
called = false
req := httptest.NewRequest(http.MethodPost, path, nil)
req.RemoteAddr = "192.168.1.42:54321"
req.Host = "robotbox.example.net"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
req.SetBasicAuth("alice", "s3cret")
req.Header.Set("X-Lerd-CSRF", "1")
rec := httptest.NewRecorder()
diff --git a/internal/ui/remote_full_access_test.go b/internal/ui/remote_full_access_test.go
new file mode 100644
index 000000000..134ebfb01
--- /dev/null
+++ b/internal/ui/remote_full_access_test.go
@@ -0,0 +1,247 @@
+package ui
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/geodro/lerd/internal/config"
+)
+
+// hostActionPaths covers one endpoint from each branch of isLoopbackOnlyPath:
+// exact match, prefix subtree, and per-site subaction.
+var hostActionPaths = []string{
+ "/api/lerd/stop",
+ "/api/logs/terminal",
+ "/api/sites/link",
+ "/api/browse",
+ "/api/tools/composer/update",
+ "/api/databases/mysql/drop",
+ "/api/sites/myapp.test/env",
+ "/api/sites/myapp.test/terminal",
+}
+
+// remoteRequest builds an authenticated request from a LAN address, carrying
+// the CSRF header so the cross-origin gate isn't what rejects it.
+func remoteRequest(method, path string) *http.Request {
+ req := httptest.NewRequest(method, path, nil)
+ req.RemoteAddr = "192.168.1.42:54321"
+ req.Host = "dashboard.example.net"
+ req.SetBasicAuth("alice", "s3cret")
+ req.Header.Set("X-Lerd-CSRF", "1")
+ return req
+}
+
+func TestHostActionRoutesBlockedFromRemoteByDefault(t *testing.T) {
+ setupConfigDirFullAccess(t, "alice", "s3cret", false)
+
+ for _, path := range hostActionPaths {
+ t.Run(path, func(t *testing.T) {
+ next := &nextHandler{}
+ gate := withRemoteControlGate(next)
+ rec := httptest.NewRecorder()
+ gate.ServeHTTP(rec, remoteRequest(http.MethodPost, path))
+
+ if next.called {
+ t.Error("host-action route reached the handler without remote full access")
+ }
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ })
+ }
+}
+
+func TestHostActionRoutesAllowedFromRemoteWithFullAccess(t *testing.T) {
+ setupConfigDirFullAccess(t, "alice", "s3cret", true)
+
+ for _, path := range hostActionPaths {
+ t.Run(path, func(t *testing.T) {
+ called := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ if !hasHostActionAuthority(r) {
+ t.Error("handler-level authority denied a request the gate admitted")
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(next).ServeHTTP(rec, remoteRequest(http.MethodPost, path))
+
+ if !called || rec.Code != http.StatusOK {
+ t.Errorf("called=%v status=%d, want true/200", called, rec.Code)
+ }
+ })
+ }
+}
+
+func TestHostActionRoutesAlwaysAllowedLocally(t *testing.T) {
+ setupConfigDirFullAccess(t, "alice", "s3cret", false)
+
+ for _, path := range hostActionPaths {
+ t.Run(path, func(t *testing.T) {
+ next := &nextHandler{}
+ req := httptest.NewRequest(http.MethodPost, path, nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
+ req.Header.Set("X-Lerd-CSRF", "1")
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(next).ServeHTTP(rec, req)
+
+ if !next.called {
+ t.Errorf("local request to %s was blocked (status %d)", path, rec.Code)
+ }
+ })
+ }
+}
+
+// Full access widens which routes an authenticated session may reach. It must
+// never stand in for authentication itself.
+func TestFullAccessStillRequiresCredentials(t *testing.T) {
+ setupConfigDirFullAccess(t, "", "", true)
+
+ next := &nextHandler{}
+ req := httptest.NewRequest(http.MethodPost, "/api/browse", nil)
+ req.RemoteAddr = "192.168.1.42:54321"
+ req.Host = "dashboard.example.net"
+ req.Header.Set("X-Lerd-CSRF", "1")
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(next).ServeHTTP(rec, req)
+
+ if next.called {
+ t.Fatal("unauthenticated remote request reached a host-action route")
+ }
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+}
+
+func TestAccessModeReportsHostActionAuthority(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ fullAccess bool
+ want bool
+ }{
+ {"default", false, false},
+ {"full access", true, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ setupConfigDirFullAccess(t, "alice", "s3cret", tc.fullAccess)
+
+ rec := httptest.NewRecorder()
+ gate := withRemoteControlGate(http.HandlerFunc(handleAccessMode))
+ gate.ServeHTTP(rec, remoteRequest(http.MethodGet, "/api/access-mode"))
+
+ var body struct {
+ LocalControl bool `json:"local_control"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body.LocalControl != tc.want {
+ t.Errorf("local_control = %v, want %v", body.LocalControl, tc.want)
+ }
+ })
+ }
+}
+
+// A remote session must not be able to widen its own authority.
+func TestRemoteSessionCannotGrantItselfFullAccess(t *testing.T) {
+ setupConfigDirFullAccess(t, "alice", "s3cret", false)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/remote-control",
+ strings.NewReader(`{"action":"full-access","enabled":true}`))
+ req.RemoteAddr = "192.168.1.42:54321"
+ req.Host = "dashboard.example.net"
+ req.SetBasicAuth("alice", "s3cret")
+ req.Header.Set("X-Lerd-CSRF", "1")
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(http.HandlerFunc(handleRemoteControl)).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if cfg.UI.RemoteFullAccess {
+ t.Fatal("a remote session granted itself full access")
+ }
+}
+
+func TestLocalRequestCanToggleFullAccess(t *testing.T) {
+ setupConfigDirFullAccess(t, "alice", "s3cret", false)
+
+ for _, tc := range []struct {
+ body string
+ want bool
+ }{
+ {`{"action":"full-access","enabled":true}`, true},
+ {`{"action":"full-access","enabled":false}`, false},
+ } {
+ req := httptest.NewRequest(http.MethodPost, "/api/remote-control", strings.NewReader(tc.body))
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Host = "localhost:7073"
+ req.Header.Set("X-Lerd-CSRF", "1")
+ rec := httptest.NewRecorder()
+ withRemoteControlGate(http.HandlerFunc(handleRemoteControl)).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
+ }
+ cfg, err := config.LoadGlobal()
+ if err != nil {
+ t.Fatalf("LoadGlobal: %v", err)
+ }
+ if cfg.UI.RemoteFullAccess != tc.want {
+ t.Fatalf("ui.remote_full_access = %v, want %v", cfg.UI.RemoteFullAccess, tc.want)
+ }
+ }
+}
+
+func TestIsLoopbackOnlyPath(t *testing.T) {
+ cases := []struct {
+ path string
+ want bool
+ }{
+ {"/api/lerd/stop", true},
+ {"/api/lerd/quit", true},
+ {"/api/logs/terminal", true},
+ {"/api/logs/lerd-nginx", false},
+ {"/api/sites/link", true},
+ {"/api/browse", true},
+ {"/api/sites/myapp.test/terminal", true},
+ {"/api/sites/foo.bar.test/terminal", true},
+ {"/api/sites/myapp.test/env", true},
+ {"/api/sites/myapp.test/env/files", true},
+ {"/api/sites/myapp.test/env/backups", true},
+ {"/api/sites/myapp.test/env/backups/.env.bkp.20260528-103045", true},
+ {"/api/sites/myapp.test/env/restore", true},
+ {"/api/sites/myapp.test/terminal/anything", true},
+ {"/api/databases", true},
+ {"/api/databases/mysql", true},
+ {"/api/databases/mysql/drop", true},
+ {"/api/databases/mysql/export", true},
+ {"/api/databases/postgres/snapshots/nightly", true},
+ {"/api/databases-overview", false},
+ {"/api/tools/composer/update", true},
+ {"/api/share-tools", false},
+ {"/api/sites", false},
+ {"/api/sites/myapp.test", false},
+ {"/api/sites/myapp.test/secure", false},
+ {"/api/sites/myapp.test/envoy", false},
+ {"/api/lerd/start", false},
+ {"/api/version", false},
+ {"/", false},
+ }
+ for _, c := range cases {
+ t.Run(c.path, func(t *testing.T) {
+ if got := isLoopbackOnlyPath(c.path); got != c.want {
+ t.Errorf("isLoopbackOnlyPath(%q) = %v, want %v", c.path, got, c.want)
+ }
+ })
+ }
+}
diff --git a/internal/ui/server.go b/internal/ui/server.go
index ad51b3130..15eb7abcd 100644
--- a/internal/ui/server.go
+++ b/internal/ui/server.go
@@ -5557,7 +5557,7 @@ func handleAppLogs(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
diff --git a/internal/ui/site_doctor.go b/internal/ui/site_doctor.go
index f8e5007d5..d4a1383bf 100644
--- a/internal/ui/site_doctor.go
+++ b/internal/ui/site_doctor.go
@@ -18,7 +18,7 @@ func doctorRoute(w http.ResponseWriter, r *http.Request, domain string, rest []s
if len(rest) == 0 || rest[0] != "doctor" {
return false
}
- if !isLoopbackRequest(r) {
+ if !hasHostActionAuthority(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return true
}
diff --git a/internal/ui/web/messages/de.json b/internal/ui/web/messages/de.json
index 6ab58471f..78da8236c 100644
--- a/internal/ui/web/messages/de.json
+++ b/internal/ui/web/messages/de.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Benutzername: {username}",
"system_remote_inertWarning": "Anmeldedaten sind gespeichert, aber inaktiv, die LAN-Freigabe ist aus, also bekommen LAN-Clients weiterhin 403. Führe {cmd} aus (oder klicke oben auf {btn}), um sie zu aktivieren.",
"system_remote_disable": "LAN-Zugriff deaktivieren",
+ "system_remote_fullAccess_title": "Host-Aktionen aus Remote-Sitzungen",
+ "system_remote_fullAccess_description": "Das Lesen der .env einer Site, das Durchsuchen des Dateisystems, das Löschen von Datenbanken, Terminals und die Befehlsausführung bleiben lokal, sofern Sie sie hier nicht erlauben.",
+ "system_remote_fullAccess_warning": "Wer das Dashboard-Passwort kennt, kann jetzt Site-Geheimnisse lesen, das Dateisystem dieses Rechners durchsuchen, Datenbanken löschen und Befehle darauf ausführen. Nur in einem vertrauenswürdigen Netzwerk verwenden.",
"system_remote_enable": "LAN-Zugriff aktivieren",
"system_remote_enableDisabledHint": "Führe zuerst lan:expose aus, ohne das ist das Dashboard nur über Loopback erreichbar und die Anmeldedaten wären inaktiv.",
"system_remote_exposeFirst": "Gib lerd zuerst im LAN frei. Dashboard-Anmeldedaten sind nur sinnvoll, solange das Dashboard von anderen Geräten erreichbar ist.",
diff --git a/internal/ui/web/messages/en.json b/internal/ui/web/messages/en.json
index bbb8cef61..552f2f416 100644
--- a/internal/ui/web/messages/en.json
+++ b/internal/ui/web/messages/en.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Username: {username}",
"system_remote_inertWarning": "Credentials are stored but inert, LAN exposure is off, so LAN clients still get 403. Run {cmd} (or click {btn} above) to make them active.",
"system_remote_disable": "Disable LAN access",
+ "system_remote_fullAccess_title": "Host actions from remote sessions",
+ "system_remote_fullAccess_description": "Reading a site's .env, browsing the filesystem, database drops, terminals and command execution stay local-only unless you allow them here.",
+ "system_remote_fullAccess_warning": "Anyone with the dashboard password can now read site secrets, browse this machine's filesystem, drop databases and run commands on it. Use only on a trusted network.",
"system_remote_enable": "Enable LAN access",
"system_remote_enableDisabledHint": "Run lan:expose first, without it the dashboard is loopback-only and credentials would be inert.",
"system_remote_exposeFirst": "Expose lerd to the LAN first. Dashboard credentials are only meaningful while the dashboard is reachable from other devices.",
diff --git a/internal/ui/web/messages/es.json b/internal/ui/web/messages/es.json
index 491f2a655..eed97ee8f 100644
--- a/internal/ui/web/messages/es.json
+++ b/internal/ui/web/messages/es.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Usuario: {username}",
"system_remote_inertWarning": "Las credenciales se guardan pero son inertes, la exposición LAN está apagada, así que los clientes LAN aún reciben 403. Ejecuta {cmd} (o pulsa {btn} arriba) para activarlas.",
"system_remote_disable": "Desactivar acceso LAN",
+ "system_remote_fullAccess_title": "Acciones del host desde sesiones remotas",
+ "system_remote_fullAccess_description": "Leer el .env de un sitio, explorar el sistema de archivos, eliminar bases de datos, los terminales y la ejecución de comandos permanecen solo en local salvo que los permitas aquí.",
+ "system_remote_fullAccess_warning": "Cualquiera con la contraseña del panel podrá leer los secretos de los sitios, explorar el sistema de archivos de esta máquina, eliminar bases de datos y ejecutar comandos en ella. Úsalo solo en una red de confianza.",
"system_remote_enable": "Activar acceso LAN",
"system_remote_enableDisabledHint": "Ejecuta lan:expose primero, sin eso el panel es solo loopback y las credenciales serían inertes.",
"system_remote_exposeFirst": "Expón lerd a la LAN primero. Las credenciales del panel solo tienen sentido mientras el panel sea accesible desde otros dispositivos.",
diff --git a/internal/ui/web/messages/fr.json b/internal/ui/web/messages/fr.json
index d06beb09a..b1b0259e0 100644
--- a/internal/ui/web/messages/fr.json
+++ b/internal/ui/web/messages/fr.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Utilisateur : {username}",
"system_remote_inertWarning": "Les identifiants sont stockés mais inertes, l'exposition LAN est désactivée, les clients LAN reçoivent donc encore 403. Exécutez {cmd} (ou cliquez {btn} ci-dessus) pour les rendre actifs.",
"system_remote_disable": "Désactiver l'accès LAN",
+ "system_remote_fullAccess_title": "Actions sur l'hôte depuis les sessions distantes",
+ "system_remote_fullAccess_description": "La lecture du .env d'un site, l'exploration du système de fichiers, la suppression de bases de données, les terminaux et l'exécution de commandes restent locaux sauf si vous les autorisez ici.",
+ "system_remote_fullAccess_warning": "Toute personne disposant du mot de passe du tableau de bord peut désormais lire les secrets des sites, explorer le système de fichiers de cette machine, supprimer des bases de données et y exécuter des commandes. À n'utiliser que sur un réseau de confiance.",
"system_remote_enable": "Activer l'accès LAN",
"system_remote_enableDisabledHint": "Exécutez lan:expose d'abord, sans cela le tableau de bord est loopback uniquement et les identifiants seraient inertes.",
"system_remote_exposeFirst": "Exposez lerd au LAN d'abord. Les identifiants n'ont de sens que tant que le tableau de bord est atteignable depuis d'autres appareils.",
diff --git a/internal/ui/web/messages/id.json b/internal/ui/web/messages/id.json
index b27cab19a..400495bfc 100644
--- a/internal/ui/web/messages/id.json
+++ b/internal/ui/web/messages/id.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Nama pengguna: {username}",
"system_remote_inertWarning": "Kredensial tersimpan tetapi inert, eksposur LAN nonaktif, jadi klien LAN tetap mendapat 403. Jalankan {cmd} (atau klik {btn} di atas) untuk mengaktifkannya.",
"system_remote_disable": "Nonaktifkan akses LAN",
+ "system_remote_fullAccess_title": "Tindakan host dari sesi jarak jauh",
+ "system_remote_fullAccess_description": "Membaca .env sebuah situs, menjelajahi sistem berkas, menghapus basis data, terminal, dan eksekusi perintah tetap hanya lokal kecuali Anda mengizinkannya di sini.",
+ "system_remote_fullAccess_warning": "Siapa pun yang memiliki kata sandi dasbor kini dapat membaca rahasia situs, menjelajahi sistem berkas mesin ini, menghapus basis data, dan menjalankan perintah di dalamnya. Gunakan hanya pada jaringan tepercaya.",
"system_remote_enable": "Aktifkan akses LAN",
"system_remote_enableDisabledHint": "Jalankan lan:expose dulu, tanpa itu dasbor hanya melalui loopback dan kredensial akan inert.",
"system_remote_exposeFirst": "Ekspos lerd ke LAN dulu. Kredensial dasbor hanya berarti selama dasbor dapat dijangkau dari perangkat lain.",
diff --git a/internal/ui/web/messages/it.json b/internal/ui/web/messages/it.json
index 708d87511..100fb4b7c 100644
--- a/internal/ui/web/messages/it.json
+++ b/internal/ui/web/messages/it.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Nome utente: {username}",
"system_remote_inertWarning": "Le credenziali sono memorizzate ma inerti, l'esposizione LAN è disattivata, quindi i client LAN ottengono ancora 403. Esegui {cmd} (o clicca {btn} qui sopra) per renderle attive.",
"system_remote_disable": "Disabilita accesso LAN",
+ "system_remote_fullAccess_title": "Azioni sull'host dalle sessioni remote",
+ "system_remote_fullAccess_description": "La lettura del .env di un sito, l'esplorazione del filesystem, l'eliminazione di database, i terminali e l'esecuzione di comandi restano solo locali se non li consenti qui.",
+ "system_remote_fullAccess_warning": "Chiunque conosca la password della dashboard può ora leggere i segreti dei siti, esplorare il filesystem di questa macchina, eliminare database ed eseguire comandi su di essa. Usa solo su una rete affidabile.",
"system_remote_enable": "Abilita accesso LAN",
"system_remote_enableDisabledHint": "Esegui prima lan:expose, senza di esso la dashboard è solo loopback e le credenziali sarebbero inerti.",
"system_remote_exposeFirst": "Esponi prima lerd alla LAN. Le credenziali della dashboard hanno senso solo finché la dashboard è raggiungibile da altri dispositivi.",
diff --git a/internal/ui/web/messages/ja.json b/internal/ui/web/messages/ja.json
index f298af912..14cb86257 100644
--- a/internal/ui/web/messages/ja.json
+++ b/internal/ui/web/messages/ja.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "ユーザー名: {username}",
"system_remote_inertWarning": "認証情報は保存されていますが不活性です。LAN 公開がオフのため、LAN クライアントは依然として 403 を受け取ります。{cmd} を実行する(または上の {btn} をクリックする)と有効になります。",
"system_remote_disable": "LAN アクセスを無効化",
+ "system_remote_fullAccess_title": "リモートセッションからのホスト操作",
+ "system_remote_fullAccess_description": "サイトの .env の読み取り、ファイルシステムの閲覧、データベースの削除、ターミナル、コマンド実行は、ここで許可しない限りローカル専用のままです。",
+ "system_remote_fullAccess_warning": "ダッシュボードのパスワードを知っていれば、サイトの機密情報の読み取り、このマシンのファイルシステムの閲覧、データベースの削除、コマンドの実行が可能になります。信頼できるネットワークでのみ使用してください。",
"system_remote_enable": "LAN アクセスを有効化",
"system_remote_enableDisabledHint": "先に lan:expose を実行してください。これがないとダッシュボードはループバックのみとなり、認証情報は不活性になります。",
"system_remote_exposeFirst": "先に lerd を LAN に公開してください。ダッシュボードの認証情報は、ダッシュボードが他のデバイスから到達可能な間のみ意味を持ちます。",
diff --git a/internal/ui/web/messages/nl.json b/internal/ui/web/messages/nl.json
index f8fc72d54..0f7d2ba09 100644
--- a/internal/ui/web/messages/nl.json
+++ b/internal/ui/web/messages/nl.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Gebruikersnaam: {username}",
"system_remote_inertWarning": "Inloggegevens zijn opgeslagen maar inert, LAN-blootstelling staat uit, dus LAN-clients krijgen nog steeds 403. Voer {cmd} uit (of klik hierboven op {btn}) om ze actief te maken.",
"system_remote_disable": "LAN-toegang uitschakelen",
+ "system_remote_fullAccess_title": "Hostacties vanuit externe sessies",
+ "system_remote_fullAccess_description": "Het lezen van de .env van een site, door het bestandssysteem bladeren, databases verwijderen, terminals en het uitvoeren van opdrachten blijven alleen lokaal, tenzij je ze hier toestaat.",
+ "system_remote_fullAccess_warning": "Iedereen met het dashboardwachtwoord kan nu sitegeheimen lezen, het bestandssysteem van deze machine doorbladeren, databases verwijderen en er opdrachten op uitvoeren. Gebruik dit alleen op een vertrouwd netwerk.",
"system_remote_enable": "LAN-toegang inschakelen",
"system_remote_enableDisabledHint": "Voer eerst lan:expose uit, zonder dat is het dashboard alleen via loopback en zouden de inloggegevens inert zijn.",
"system_remote_exposeFirst": "Stel lerd eerst bloot aan het LAN. Dashboard-inloggegevens zijn alleen zinvol zolang het dashboard bereikbaar is vanaf andere apparaten.",
diff --git a/internal/ui/web/messages/pl.json b/internal/ui/web/messages/pl.json
index b7d6da6ac..6bf9801eb 100644
--- a/internal/ui/web/messages/pl.json
+++ b/internal/ui/web/messages/pl.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Nazwa użytkownika: {username}",
"system_remote_inertWarning": "Poświadczenia są zapisane, ale nieaktywne, ekspozycja w LAN jest wyłączona, więc klienci LAN nadal otrzymują 403. Uruchom {cmd} (lub kliknij {btn} powyżej), aby je aktywować.",
"system_remote_disable": "Wyłącz dostęp z LAN",
+ "system_remote_fullAccess_title": "Działania na hoście z sesji zdalnych",
+ "system_remote_fullAccess_description": "Odczyt pliku .env witryny, przeglądanie systemu plików, usuwanie baz danych, terminale i wykonywanie poleceń pozostają wyłącznie lokalne, chyba że zezwolisz na nie tutaj.",
+ "system_remote_fullAccess_warning": "Każdy, kto zna hasło panelu, może teraz odczytywać sekrety witryn, przeglądać system plików tej maszyny, usuwać bazy danych i uruchamiać na niej polecenia. Używaj tylko w zaufanej sieci.",
"system_remote_enable": "Włącz dostęp z LAN",
"system_remote_enableDisabledHint": "Najpierw uruchom lan:expose, bez tego pulpit jest dostępny tylko z loopback, a poświadczenia byłyby nieaktywne.",
"system_remote_exposeFirst": "Najpierw wystaw lerd w LAN. Poświadczenia pulpitu mają sens tylko wtedy, gdy pulpit jest osiągalny z innych urządzeń.",
diff --git a/internal/ui/web/messages/pt.json b/internal/ui/web/messages/pt.json
index 10fef804b..75e9f09a5 100644
--- a/internal/ui/web/messages/pt.json
+++ b/internal/ui/web/messages/pt.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Usuário: {username}",
"system_remote_inertWarning": "Credenciais estão salvas mas inertes, exposição LAN está off, então clientes LAN ainda recebem 403. Execute {cmd} (ou clique em {btn} acima) para ativá-las.",
"system_remote_disable": "Desativar acesso LAN",
+ "system_remote_fullAccess_title": "Ações no host a partir de sessões remotas",
+ "system_remote_fullAccess_description": "Ler o .env de um site, navegar pelo sistema de ficheiros, eliminar bases de dados, terminais e execução de comandos permanecem apenas locais, a menos que os permita aqui.",
+ "system_remote_fullAccess_warning": "Qualquer pessoa com a palavra-passe do painel pode agora ler os segredos dos sites, navegar pelo sistema de ficheiros desta máquina, eliminar bases de dados e executar comandos nela. Use apenas numa rede de confiança.",
"system_remote_enable": "Ativar acesso LAN",
"system_remote_enableDisabledHint": "Execute lan:expose primeiro, sem isso o painel é só loopback e as credenciais seriam inertes.",
"system_remote_exposeFirst": "Exponha o lerd à LAN primeiro. Credenciais do painel só fazem sentido enquanto o painel é acessível de outros dispositivos.",
diff --git a/internal/ui/web/messages/ro.json b/internal/ui/web/messages/ro.json
index 8571a30fa..c6b468b5c 100644
--- a/internal/ui/web/messages/ro.json
+++ b/internal/ui/web/messages/ro.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Utilizator: {username}",
"system_remote_inertWarning": "Credențialele sunt stocate dar inerte, expunerea LAN este oprită, deci clienții din LAN primesc în continuare 403. Execută {cmd} (sau apasă {btn} de mai sus) pentru a le activa.",
"system_remote_disable": "Dezactivează accesul LAN",
+ "system_remote_fullAccess_title": "Acțiuni pe gazdă din sesiunile la distanță",
+ "system_remote_fullAccess_description": "Citirea fișierului .env al unui sit, navigarea în sistemul de fișiere, ștergerea bazelor de date, terminalele și rularea comenzilor rămân doar locale dacă nu le permiți aici.",
+ "system_remote_fullAccess_warning": "Oricine are parola tabloului de bord poate acum să citească secretele siturilor, să navigheze în sistemul de fișiere al acestei mașini, să șteargă baze de date și să ruleze comenzi pe ea. Folosește doar într-o rețea de încredere.",
"system_remote_enable": "Activează accesul LAN",
"system_remote_enableDisabledHint": "Execută mai întâi lan:expose, fără el tabloul de bord este doar loopback, iar credențialele ar fi inerte.",
"system_remote_exposeFirst": "Expune mai întâi lerd la LAN. Credențialele tabloului de bord sunt relevante doar cât timp tabloul de bord este accesibil de pe alte dispozitive.",
diff --git a/internal/ui/web/messages/tr.json b/internal/ui/web/messages/tr.json
index 2fae8c0e1..7ebb542d5 100644
--- a/internal/ui/web/messages/tr.json
+++ b/internal/ui/web/messages/tr.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Kullanıcı adı: {username}",
"system_remote_inertWarning": "Kimlik bilgileri kaydedildi ama atıl, LAN erişimi kapalı, dolayısıyla LAN istemcileri hâlâ 403 alır. Aktif yapmak için {cmd} çalıştırın (veya yukarıdaki {btn} düğmesine tıklayın).",
"system_remote_disable": "LAN erişimini devre dışı bırak",
+ "system_remote_fullAccess_title": "Uzak oturumlardan ana makine işlemleri",
+ "system_remote_fullAccess_description": "Bir sitenin .env dosyasını okuma, dosya sistemine göz atma, veritabanı silme, terminaller ve komut çalıştırma, burada izin vermediğiniz sürece yalnızca yerel kalır.",
+ "system_remote_fullAccess_warning": "Panel parolasına sahip herkes artık site sırlarını okuyabilir, bu makinenin dosya sistemine göz atabilir, veritabanlarını silebilir ve üzerinde komut çalıştırabilir. Yalnızca güvenilir bir ağda kullanın.",
"system_remote_enable": "LAN erişimini etkinleştir",
"system_remote_enableDisabledHint": "Önce lan:expose çalıştırın, onsuz pano yalnızca loopback'tedir ve kimlik bilgileri atıl olur.",
"system_remote_exposeFirst": "Önce Lerd'i LAN'a açın. Pano kimlik bilgileri yalnızca pano diğer cihazlardan erişilebilir olduğunda anlamlıdır.",
diff --git a/internal/ui/web/messages/vi.json b/internal/ui/web/messages/vi.json
index e708d2582..abe7aa7be 100644
--- a/internal/ui/web/messages/vi.json
+++ b/internal/ui/web/messages/vi.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "Tên đăng nhập: {username}",
"system_remote_inertWarning": "Thông tin đăng nhập đã được lưu nhưng không hoạt động, mở ra LAN đang tắt, nên các client LAN vẫn nhận 403. Chạy {cmd} (hoặc nhấp {btn} bên trên) để kích hoạt chúng.",
"system_remote_disable": "Tắt truy cập LAN",
+ "system_remote_fullAccess_title": "Thao tác trên máy chủ từ phiên từ xa",
+ "system_remote_fullAccess_description": "Việc đọc tệp .env của một trang, duyệt hệ thống tệp, xóa cơ sở dữ liệu, terminal và chạy lệnh vẫn chỉ dành cho cục bộ trừ khi bạn cho phép tại đây.",
+ "system_remote_fullAccess_warning": "Bất kỳ ai có mật khẩu bảng điều khiển giờ đây đều có thể đọc bí mật của trang, duyệt hệ thống tệp của máy này, xóa cơ sở dữ liệu và chạy lệnh trên đó. Chỉ dùng trên mạng đáng tin cậy.",
"system_remote_enable": "Bật truy cập LAN",
"system_remote_enableDisabledHint": "Chạy lan:expose trước, không có nó bảng điều khiển chỉ ở loopback và thông tin đăng nhập sẽ không hoạt động.",
"system_remote_exposeFirst": "Mở lerd ra LAN trước. Thông tin đăng nhập bảng điều khiển chỉ có ý nghĩa khi bảng điều khiển có thể truy cập từ các thiết bị khác.",
diff --git a/internal/ui/web/messages/zh.json b/internal/ui/web/messages/zh.json
index 85afc756e..7cfe3de24 100644
--- a/internal/ui/web/messages/zh.json
+++ b/internal/ui/web/messages/zh.json
@@ -675,6 +675,9 @@
"system_remote_usernameRow": "用户名:{username}",
"system_remote_inertWarning": "凭据已存储但未生效,LAN 暴露已关闭,因此 LAN 客户端仍会收到 403。运行 {cmd}(或点击上方的 {btn})使其生效。",
"system_remote_disable": "禁用 LAN 访问",
+ "system_remote_fullAccess_title": "来自远程会话的主机操作",
+ "system_remote_fullAccess_description": "读取站点的 .env、浏览文件系统、删除数据库、终端和执行命令仍仅限本地,除非你在此处允许。",
+ "system_remote_fullAccess_warning": "知道仪表板密码的任何人现在都可以读取站点机密、浏览本机文件系统、删除数据库并在其上运行命令。请仅在受信任的网络中使用。",
"system_remote_enable": "启用 LAN 访问",
"system_remote_enableDisabledHint": "请先运行 lan:expose,否则仪表盘仅限回环,凭据将不会生效。",
"system_remote_exposeFirst": "请先将 lerd 暴露到 LAN。仪表盘凭据只有在仪表盘可从其他设备访问时才有意义。",
diff --git a/internal/ui/web/src/stores/remoteControl.test.ts b/internal/ui/web/src/stores/remoteControl.test.ts
new file mode 100644
index 000000000..6d5f0faf3
--- /dev/null
+++ b/internal/ui/web/src/stores/remoteControl.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { get } from 'svelte/store';
+import { remoteControl, loadRemoteControl, setRemoteFullAccess } from './remoteControl';
+
+describe('remote control host-action opt-in', () => {
+ const realFetch = globalThis.fetch;
+
+ beforeEach(() => {
+ remoteControl.set({
+ enabled: false,
+ username: '',
+ fullAccess: false,
+ fullAccessLoading: false,
+ loading: false,
+ error: ''
+ });
+ });
+
+ afterEach(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ it('loads the host-action setting alongside the credentials', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ new Response(JSON.stringify({ enabled: true, username: 'alice', full_access: true }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+ ) as unknown as typeof fetch;
+
+ await loadRemoteControl();
+ expect(get(remoteControl)).toMatchObject({ enabled: true, username: 'alice', fullAccess: true });
+ });
+
+ it('defaults to local-only when the backend omits the setting', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ new Response(JSON.stringify({ enabled: true, username: 'alice' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+ ) as unknown as typeof fetch;
+
+ await loadRemoteControl();
+ expect(get(remoteControl).fullAccess).toBe(false);
+ });
+
+ it('posts the full-access action and stores what the backend confirms', async () => {
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ expect(JSON.parse(String(init?.body))).toEqual({ action: 'full-access', enabled: true });
+ return new Response(JSON.stringify({ ok: true, full_access: true }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ });
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
+
+ expect(await setRemoteFullAccess(true)).toBe(true);
+ expect(fetchMock).toHaveBeenCalled();
+ expect(get(remoteControl)).toMatchObject({ fullAccess: true, fullAccessLoading: false });
+ });
+
+ it('surfaces a rejection without flipping the toggle', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ new Response('Forbidden — remote full access can only be changed from the lerd host.', { status: 403 })
+ ) as unknown as typeof fetch;
+
+ expect(await setRemoteFullAccess(true)).toBe(false);
+ const state = get(remoteControl);
+ expect(state.fullAccess).toBe(false);
+ expect(state.fullAccessLoading).toBe(false);
+ expect(state.error).toContain('only be changed from the lerd host');
+ });
+});
diff --git a/internal/ui/web/src/stores/remoteControl.ts b/internal/ui/web/src/stores/remoteControl.ts
index 0deb0eba3..9d0ac5baf 100644
--- a/internal/ui/web/src/stores/remoteControl.ts
+++ b/internal/ui/web/src/stores/remoteControl.ts
@@ -5,17 +5,27 @@ import { apiJson, apiFetch } from '$lib/api';
export interface RemoteControl {
enabled: boolean;
username: string;
+ fullAccess: boolean;
+ fullAccessLoading: boolean;
loading: boolean;
error: string;
}
-const empty: RemoteControl = { enabled: false, username: '', loading: false, error: '' };
+const empty: RemoteControl = {
+ enabled: false,
+ username: '',
+ fullAccess: false,
+ fullAccessLoading: false,
+ loading: false,
+ error: ''
+};
export const remoteControl = writable(empty);
interface RemoteControlResponse {
enabled?: boolean;
username?: string;
+ full_access?: boolean;
error?: string;
}
@@ -23,10 +33,10 @@ export async function loadRemoteControl() {
try {
const data = await apiJson('/api/remote-control');
remoteControl.set({
+ ...empty,
enabled: Boolean(data.enabled),
username: data.username || '',
- loading: false,
- error: ''
+ fullAccess: Boolean(data.full_access)
});
} catch (e) {
remoteControl.update((v) => ({
@@ -51,7 +61,7 @@ export async function enableRemoteControl(username: string, password: string): P
}
const data = (await res.json()) as { ok?: boolean; error?: string };
if (data.ok) {
- remoteControl.set({ enabled: true, username, loading: false, error: '' });
+ remoteControl.update((v) => ({ ...v, enabled: true, username, loading: false, error: '' }));
return { ok: true };
}
remoteControl.update((v) => ({ ...v, loading: false, error: data.error || m.common_failed() }));
@@ -78,7 +88,7 @@ export async function disableRemoteControl(): Promise {
}
const data = (await res.json()) as { ok?: boolean; error?: string };
if (data.ok) {
- remoteControl.set({ enabled: false, username: '', loading: false, error: '' });
+ remoteControl.set(empty);
return true;
}
remoteControl.update((v) => ({ ...v, loading: false, error: data.error || m.common_failed() }));
@@ -88,3 +98,36 @@ export async function disableRemoteControl(): Promise {
return false;
}
}
+
+// setRemoteFullAccess opts authenticated remote sessions into the host actions
+// that are otherwise local-only. The backend accepts it from the local
+// dashboard only, so a remote session cannot widen its own authority.
+export async function setRemoteFullAccess(enabled: boolean): Promise {
+ remoteControl.update((v) => ({ ...v, fullAccessLoading: true, error: '' }));
+ try {
+ const res = await apiFetch('/api/remote-control', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'full-access', enabled })
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ remoteControl.update((v) => ({ ...v, fullAccessLoading: false, error: text || `HTTP ${res.status}` }));
+ return false;
+ }
+ const data = (await res.json()) as { ok?: boolean; full_access?: boolean; error?: string };
+ if (data.ok) {
+ remoteControl.update((v) => ({ ...v, fullAccess: Boolean(data.full_access), fullAccessLoading: false }));
+ return true;
+ }
+ remoteControl.update((v) => ({ ...v, fullAccessLoading: false, error: data.error || m.common_failed() }));
+ return false;
+ } catch (e) {
+ remoteControl.update((v) => ({
+ ...v,
+ fullAccessLoading: false,
+ error: e instanceof Error ? e.message : m.common_requestFailed()
+ }));
+ return false;
+ }
+}
diff --git a/internal/ui/web/src/tabs/system/LerdDetail.svelte b/internal/ui/web/src/tabs/system/LerdDetail.svelte
index 1437af6e6..e6d957057 100644
--- a/internal/ui/web/src/tabs/system/LerdDetail.svelte
+++ b/internal/ui/web/src/tabs/system/LerdDetail.svelte
@@ -8,7 +8,8 @@
import {
remoteControl,
loadRemoteControl,
- disableRemoteControl
+ disableRemoteControl,
+ setRemoteFullAccess
} from '$stores/remoteControl';
import { openRemoteControlModal, openLANProgressModal, type LANAction } from '$stores/modals';
import { autostartEnabled, loadAutostart, toggleAutostart } from '$stores/autostart';
@@ -470,6 +471,24 @@
{@html m.system_remote_inertWarning({ cmd: 'lerd lan:expose', btn: '' + m.system_lan_expose() + '' })}
{/if}
+