feat(config): public_dir override in .lerd.yaml#370
Merged
Conversation
Projects with a non-standard document root (e.g. a Laravel skeleton using public_html/ instead of public/) can now declare it directly in .lerd.yaml without having to clone the entire framework definition. Resolves issue #369, where the user was working around the limitation with a symlink. The Site struct already had a PublicDir field and resolvePublicDir() already preferred it over the framework default; what was missing was a route to populate it from the per-project file. ProjectConfig gains a public_dir field, lerd link threads it into the site, and lerd init preserves it on re-runs. The existing nginx vhost templates already use the resolved value, so no template churn was needed. Also promotes the Configuration page from /reference/configuration to a top-level /configuration entry in the docs nav, fixes a VitePress interpolation bug where {{...}} placeholders inside inline backticks rendered as empty <code> tags on the configuration, git-worktrees, and services pages, and documents the new field with a short Custom public folder example.
Closed
Merged
geodro
added a commit
that referenced
this pull request
May 18, 2026
First beta of the 1.21.0 line. The headline is desktop notifications via Web Push (#353), with a per-category settings page polished alongside a dashboard health row (#354). The PHP-FPM image grows a real shell environment, zsh plus starship plus eza, bat, fzf, zoxide, isolated from the host (#358), then loses around 800 MB of build toolchain in a multi-stage split that drops the image from 1.36 GB to 535 MB without losing any of its 68 PHP modules (#364). A new on-demand commands feature surfaces one-shot framework actions across the dashboard, the lerd run CLI, the command palette, and four new MCP tools, all backed by a generalised Dropdown component that replaces every native select in the UI (#363). The site detail header gets a browser-style address bar with the favicon, TLS lock, LAN-share chip, and worktrees promoted from a dropdown to tabs (#365), an Env tab joins Overview, Tinker, and Dumps to show the project .env verbatim (#366), and the tray menu picks up Dump bridge and Notifications toggles that update live via a new KindDumpsStatus event (#373). Postgres grows 17 and 18 alternates alongside a new MySQL 9.7 LTS line, all gated by a canonical-version pin so flipping the yaml canonical no longer silently major-jumps existing installs (#361). Türkçe joins the dashboard languages (#355), a public_dir override lands in .lerd.yaml for projects with a non-standard document root (#370), every git invocation in the tree now flows through internal/git (#356), and worker-failure pushes are batched so a systemd cascade no longer fires six near-identical notifications back to back (#372). Plus the post-1.20.2 fix queue covers the worktree-manager button rendering on non-git sites (#357), TLS certs not refreshing when a secured site's domain set changed (#367), streamed worktree install and a wave of audit follow-ups (#368), and tinker swallowing bare-expression results when the dump bridge was on (#371).
geodro
added a commit
that referenced
this pull request
May 19, 2026
lerd-ui binds 0.0.0.0:7073 so the dashboard is reachable from anything sharing the user's WiFi. Three endpoints leaked through that surface. GET /api/sites/{domain}/env returned the raw .env to anyone who could open the port, complete with APP_KEY, DB credentials, and any third-party tokens; POST /api/push/test let a LAN attacker fire pushes onto the user's subscribed devices; POST /api/webhooks/mailpit accepted attacker-supplied subject/from with no auth. All three now refuse callers whose source IP isn't loopback. The mailpit webhook additionally accepts requests whose source matches one of the host's own interface IPs, because pasta source-NATs the mailpit container to that address when it reaches host.containers.internal. TCP spoofing of a host-owned address fails the handshake, so the gate stays tight.
A separate vector ran through .lerd.yaml's public_dir override added in #370. The nginx vhost template concatenates it into "root {{.Path}}/{{.PublicDir}};", and nothing was rejecting "../../etc" before it landed there, so cloning a hostile repo into a parked directory pivoted the document root out of the project. ValidatePublicDir now rejects absolute paths, leading ~, NUL bytes, and any path segment of "..". LoadProjectConfig calls it and silently falls back to the framework default with a warn when the value is bad, and resolvePublicDir double-checks at the nginx render layer as defence in depth.
geodro
added a commit
that referenced
this pull request
May 19, 2026
…date public_dir
Three dashboard endpoints leaked through the LAN-exposed listener: GET /api/sites/{domain}/env handed out the raw .env (APP_KEY, DB credentials, third-party tokens) once the user enabled lan:expose, POST /api/push/test let a LAN attacker spam pushes onto subscribed devices, and POST /api/webhooks/mailpit was passing through the remote-control gate unconditionally so anyone reachable from the host could fire fake mail notifications with attacker-controlled subject and from. The first two now ride the existing loopbackOnlyRoutes and loopbackOnlySiteSubactions lists in withRemoteControlGate so they are 403'd from non-loopback callers regardless of whether LAN exposure or remote-control credentials are configured. The mailpit bypass keeps the pre-auth path mailpit needs but tightens the check to fromHost(r), which verifies the source IP belongs to one of the host's interfaces, pasta on Linux and gvproxy or vmnet on macOS source-NAT the container to that address, while a LAN attacker arrives from somewhere else. Spoofing a host-owned IP off-host breaks the TCP handshake because the SYN-ACK routes back into the host instead of reaching them, so the gate stays tight.
A separate vector ran through .lerd.yaml's public_dir override added in #370. The nginx vhost template concatenates it into "root {{.Path}}/{{.PublicDir}};", and nothing was rejecting "../../etc" before it landed there, so cloning a hostile repo into a parked directory pivoted the document root out of the project. ValidatePublicDir now rejects absolute paths, leading ~, NUL bytes, and any segment of "..". LoadProjectConfig calls it and silently falls back to the framework default with a warn when the value is bad, and resolvePublicDir double-checks at the nginx render layer as defence in depth.
geodro
added a commit
that referenced
this pull request
May 19, 2026
…date public_dir (#382) * fix(ui): close LAN-reachable endpoints via the existing gate and validate public_dir Three dashboard endpoints leaked through the LAN-exposed listener: GET /api/sites/{domain}/env handed out the raw .env (APP_KEY, DB credentials, third-party tokens) once the user enabled lan:expose, POST /api/push/test let a LAN attacker spam pushes onto subscribed devices, and POST /api/webhooks/mailpit was passing through the remote-control gate unconditionally so anyone reachable from the host could fire fake mail notifications with attacker-controlled subject and from. The first two now ride the existing loopbackOnlyRoutes and loopbackOnlySiteSubactions lists in withRemoteControlGate so they are 403'd from non-loopback callers regardless of whether LAN exposure or remote-control credentials are configured. The mailpit bypass keeps the pre-auth path mailpit needs but tightens the check to fromHost(r), which verifies the source IP belongs to one of the host's interfaces, pasta on Linux and gvproxy or vmnet on macOS source-NAT the container to that address, while a LAN attacker arrives from somewhere else. Spoofing a host-owned IP off-host breaks the TCP handshake because the SYN-ACK routes back into the host instead of reaching them, so the gate stays tight. A separate vector ran through .lerd.yaml's public_dir override added in #370. The nginx vhost template concatenates it into "root {{.Path}}/{{.PublicDir}};", and nothing was rejecting "../../etc" before it landed there, so cloning a hostile repo into a parked directory pivoted the document root out of the project. ValidatePublicDir now rejects absolute paths, leading ~, NUL bytes, and any segment of "..". LoadProjectConfig calls it and silently falls back to the framework default with a warn when the value is bad, and resolvePublicDir double-checks at the nginx render layer as defence in depth. * fix(ui): mailpit webhook gate test handles IPv6 hosts The first non-loopback IP picked from net.InterfaceAddrs() on a macOS runner was fe80::1 (link-local on lo0), and the test was building RemoteAddr by string-concatenating "fe80::1" + ":34567" which is not parseable as an IPv6 host plus port. SplitHostPort errored, fromHost returned false, and the gate 403'd what should have been an allowed call. Use net.JoinHostPort to bracket the IPv6 address and prefer IPv4 candidates to dodge zone-suffix differences between Linux and macOS interface tables. * fix(ui): fromHost handles zoned IPv6 sources and tests cover both stacks The previous shape compared the source IP as a string against ipNet.IP.String(), so a request from fe80::1%eth0 would never match the zoneless fe80::1 the interface table reports. Now fromHost strips the zone, parses the source via net.ParseIP, and compares with IP.Equal so v4 and v6 both work and link-local zone suffixes pass through cleanly. The mailpit gate test grew into subtests that exercise v4 host, v6 host, and the two deny cases against a documentation-range LAN IP plus a 2001:db8 source, so neither stack can regress without a CI signal, and a dedicated TestFromHost_acceptsZonedIPv6Source pins the zone-stripping behaviour. * test(ui): pin v4-mapped-v6 acceptance in the mailpit gate A v6-only client connecting to a v4 listener arrives with RemoteAddr like [::ffff:HOSTV4]:port. fromHost relies on net.IP.Equal to normalise that against the plain v4 entry the interface table reports, but nothing was locking the behaviour down so a future readability pass could revert to a string compare without CI noticing. The new allow_v4_mapped_v6 subtest pins it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #369. Projects with a non-standard document root, such as a Laravel skeleton that uses
public_html/instead of the conventionalpublic/, can now set it in.lerd.yaml:On
lerd linkthe value flows into the site's PublicDir, and the existing nginx vhost template rendersroot /path/to/project/public_html;. No need to clone the framework definition just to change one folder, which is what the issue author was working around with a symlink.The plumbing was almost all there already. Site.PublicDir existed, resolvePublicDir() already preferred it over the framework default, and the vhost template already used the resolved value. What was missing was a way for the project file to feed into Site.PublicDir without piggybacking on the autodetect-when-no-framework-matched path. ProjectConfig gains a public_dir field, link.go threads it through, and init.go preserves it on re-runs of the wizard. The MCP framework_add tool already exposed public_dir, so MCP users were never blocked, but everyone else needed this.
Docs
While in there I promoted the Configuration page from
/reference/configurationto a top-level/configurationnav entry since the user mentioned having trouble finding it tucked under Reference. Also fixed a long-standing VitePress quirk where{{...}}placeholders inside inline backticks rendered as empty<code>tags on the configuration, git-worktrees, and services pages, wrapping the affected occurrences in<code v-pre>so Vue's template engine leaves them alone.