Skip to content

feat: add opt-in LAN access for managed services - #1247

Merged
geodro merged 4 commits into
lerd-env:mainfrom
381181295:feat/opt-in-lan-services
Jul 31, 2026
Merged

feat: add opt-in LAN access for managed services#1247
geodro merged 4 commits into
lerd-env:mainfrom
381181295:feat/opt-in-lan-services

Conversation

@381181295

@381181295 381181295 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Add explicit, opt-in LAN access for Lerd-managed databases, caches, mail, and custom services.

  • Add lerd lan:services on|off|status.
  • Persist the setting independently from normal LAN exposure.
  • Rebind installed managed services when either LAN setting changes.
  • Apply the policy automatically when services start or change ports.
  • Add controls and effective-state reporting to the dashboard, CLI status, TUI, and system tray.
  • Require authentication for remote dashboard access.
  • Make remote host actions explicitly opt in with lerd remote-control full-access on|off|status.
  • Prevent reverse proxies from bypassing authentication merely because they connect over loopback.

Behavior

Managed service ports are reachable from the LAN only when both settings are enabled:

lerd lan:expose
lerd lan:services on

The managed-service setting defaults to off.

Disabling either setting returns managed service ports to loopback. Inactive services remain stopped during rebinding. Site and worker containers are not exposed by this setting.

The interfaces distinguish between:

  • disabled
  • enabled but inactive because LAN exposure is off
  • enabled and reachable

The dashboard and CLI warn that development services can use weak or empty credentials and should only be exposed on trusted networks with appropriate firewall rules.

Remote dashboard requests must pass the existing LAN exposure and Basic/session authentication gates. After authentication, the remote session can use ordinary dashboard controls. Host actions remain local-only unless full access is explicitly enabled from the Lerd host. Enabled host actions run on the machine that runs Lerd.

Testing

  • make test
  • CGO_ENABLED=1 go vet ./...
  • gofmt -l .
  • Frontend access-mode and affected component tests
  • Production dashboard build
  • Handler-level authority tests for Basic-auth and session-cookie requests
  • Exact browser control comparison between robotbox through a local SSH tunnel and through the authenticated Tailscale URL with remote full access enabled:
    • System controls matched
    • Sites controls matched
    • Services controls matched
  • Manual browser checks for disabled, configured-but-inactive, active, local, and authenticated remote states
  • Manual authenticated remote browser mutation: managed-service LAN access toggled off and back on
  • Manual Redis checks:
    • connection refused while exposure was inactive
    • PING returned PONG while exposure was active
  • Full frontend run: 143 files passed; src/lib/notify.test.ts notification-dispatch test timed out

@381181295
381181295 requested a review from a team as a code owner July 31, 2026 03:02
@381181295
381181295 force-pushed the feat/opt-in-lan-services branch 3 times, most recently from 9dea20b to 0c4b028 Compare July 31, 2026 08:17
@381181295
381181295 force-pushed the feat/opt-in-lan-services branch from 0c4b028 to 051104d Compare July 31, 2026 08:20
geodro added 2 commits July 31, 2026 17:50
Restart failures no longer stop the rebind loop. Every affected unit is attempted and the failures are reported together, so one container that cannot come back does not decide the fate of the ones queued behind it.

Which units to restart is also no longer read from whether the file changed on this pass. A toggle that was interrupted leaves the quadlets already rewritten while the containers still run their old bind, and a later run then found nothing to do and reported success while a service was still answering on the network. Each installed container's published ports are now compared against the policy it should be following, so that drift is noticed and cleared instead of surviving every subsequent run.
An authenticated remote session drives the dashboard, but the actions that reach the host itself stay local unless the user asks for them: raw .env reads, filesystem browsing, database drops, terminals, tooling replacement and shutting lerd down. The list guarding those routes is unchanged, and the gate now consults ui.remote_full_access rather than refusing outright, so an install that has not opted in answers a remote client exactly as before.

The opt-in lives on lerd remote-control full-access on/off/status and on a toggle in the Remote dashboard access card. Both refuse to run from a remote session, so a session can never widen its own authority, and remote-control off clears the setting along with the credentials instead of leaving it armed for whatever password is set next.

Authority is asked for by name now. hasHostActionAuthority covers the handlers that already sit behind the gate, hasDashboardControl covers the ones that have to hold up on their own, and isLoopbackRequest means loopback again rather than doubling as a stand-in for an authenticated session.

A loopback request carrying X-Forwarded-For, X-Forwarded-Host, X-Real-IP or Forwarded is treated as remote, since a reverse proxy relaying someone else's browser also connects from 127.0.0.1. The Host header is no longer part of that decision, because a browser on the machine may legitimately arrive as the hostname or an /etc/hosts alias, and turning those away leaves the local user with no way in and no credential that helps.
@geodro

geodro commented Jul 31, 2026

Copy link
Copy Markdown
Member

thanks for this, the LAN services half is good work and I took it more or less as it stands. the unit aware bind policy actually fixes an inconsistency that was already on main, where only nginx was regenerated but WriteQuadlet applied BindForLAN to every quadlet, so a service that got rewritten for an unrelated reason quietly picked up a 0.0.0.0 bind while exposed. and the IPv6 pairing addition is needed, without it the 127.0.0.1 line is lost on the way back from LAN to loopback, I checked that on a real install.

I pushed two commits to your branch rather than sending you round again, hope that is alright.

the first one is about the loopback only route list. I understand the reasoning in the description, if you trusted the password enough to expose the dashboard then why cripple it, and that is a fair position, but I want it opt in rather than the default. as it stood, one password stood between the LAN and every site's .env, the host filesystem, database drops and command execution, and there is no lockout on that basic auth path, I measured 100 wrong guesses in 4 seconds with the correct one still accepted afterwards. so the list is back and the gate consults a new ui.remote_full_access setting instead of refusing outright. lerd remote-control full-access on gets you what your branch did, there is a toggle in the dashboard card too, and both refuse to run from a remote session so a session cannot widen its own authority. I also renamed the authority helpers, isLoopbackRequest was returning true for authenticated remote sessions and about a dozen handlers gate on it, so the next handler someone adds would have inherited remote authority just by using the obvious looking function.

the second commit is a bug. if a container fails to restart during a rebind the loop returned on the first error, and because the quadlets on disk were already rewritten, a later run found nothing to do. I reproduced it by interrupting lan:services off two seconds in, which left the config saying off, the file saying loopback, lan:services status saying loopback only, and redis still answering PING from another machine on 6379. lan:unexpose did not clear it either. now every unit is attempted and the failures are reported together, and the restart decision compares each container's actual published ports against the policy, so an interrupted toggle heals on the next run.

one more thing folded into the first commit, the Host header check in isLocalControlRequest. the intent is right, a proxy connecting from 127.0.0.1 should not be trusted, but requiring a localhost Host also locks out a browser on the machine itself reaching lerd by the hostname, which on Debian and Ubuntu resolves to 127.0.1.1, and there is no credential that helps in that state. it now keys off X-Forwarded-For and friends instead, which is what a proxy actually adds, so Tailscale Serve still faces the auth gate and the local user keeps getting in.

could you update the description when you get a chance, the line about authenticated remote sessions receiving the same authority as local ones is no longer what the branch does. I tested all of this on an Ubuntu 26.04 guest, services reachable and not reachable both ways, the toggle in both directions, and the interrupted toggle case.

@geodro
geodro merged commit da21579 into lerd-env:main Jul 31, 2026
3 checks passed
@381181295

Copy link
Copy Markdown
Contributor Author

thanks, no issue with you pushing the fixes, both make sense. i'll update the PR description

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants