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. ![Live PHP-FPM log tab](/assets/screenshots/site-detail-phpfpm.png) @@ -136,11 +136,11 @@ The header has a **+** button that opens the **preset picker modal**: a one-clic ![Service preset picker modal](/assets/screenshots/preset-picker-modal.png) -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 @@ {/if} - {#if $accessMode.loopback} + {#if $accessMode.localControl} {/if} - {#if $accessMode.loopback} + {#if $accessMode.localControl}