Skip to content

feat(nginx): a Front controller rule sets where requests that match no file go — GH #1999 - #2005

Merged
shukiv merged 2 commits into
mainfrom
gh1999-front-controller-rule
Oct 4, 2026
Merged

shukiv merged 2 commits into
mainfrom
gh1999-front-controller-rule

Conversation

@shukiv

@shukiv shukiv commented Oct 4, 2026

Copy link
Copy Markdown
Owner

GH #1999

What

lxsdevcode runs an app with its own router on a subdomain. Requests that match no file must reach PHP as try_files $uri $uri/ /index.php?mod=$uri&$args;, and static files must still be served directly. Today they can't get that:

  • A raw location / in the directives drops the panel's PHP locations (RootOverridden).
  • A catch-all rewrite also sends CSS, JS and images to PHP.

This adds a typed Rule Builder kind, Front Controller (front_controller: script + query). Admins get it, and so do owners when tenant domain options is on (same gate as Deny paths and Static cache).

  • Shared grammar, internal/frontcontroller. It is checked at save in the API and again at render in the agent.
    • The script is a .php path from the docroot, with no .. or //.
    • The query takes only [A-Za-z0-9_.-=&/%] literals plus $args $document_uri $is_args $query_string $request_uri $uri. A variable name is read to its end, so $urix is rejected.
    • That keeps the fallback a single nginx token.
  • API. The rule is validated, limited to one per domain, and added to tenantSafeNginxRuleTypes.
  • Panel → agent. nginxrules.Compile renders nothing for the rule; a second location / would trip RootOverridden. nginxrules.FrontController sends the fallback as the new php_fallback param.
  • Agent. The template's fallback is now {{.TryFilesFallback}}. The method re-validates and falls back to /index.php?$query_string, so an invalid value never reaches nginx, and a vhostData that never set it renders the default. PHP locations are untouched.
  • nginx import. location / { try_files $uri $uri/ <script>?<query>; } maps onto the rule, so the reporter's exact Plesk block imports. Any other root location stays a security warning.
  • UI.
    • A Front Controller card with a live try_files preview.
    • The picker hides the kind once a domain has one, and an imported one replaces the existing rule.
  • Docs. User Domains page, server settings, nginx runbook, and an ADR-0169 addendum.

Risk

GitNexus detect-changes rates this critical. That is because vhostTemplate touches every vhost. Mitigations:

  • TestVhost_NoFrontControllerIsByteIdentical renders five vhost shapes (php, static, php+cache, php+intercept, no cert). Each one is byte-identical to the template with the old fallback written out literally.
  • The changed symbols are as intended. The Domain struct hunks that showed up came from gofmt realigning existing fields; I reverted them.
  • The agent decodes params without DisallowUnknownFields, so a new panel talking to an old agent still renders, just without the rule.

Known interactions (documented, not changed)

  • Page cache. With the FastCGI micro-cache on, a non-default query reaches location = /index.php with a non-empty $query_string. Then $jabali_qs_kind is other, so routed pages bypass the page cache. This fails closed. Existing files are unaffected.
  • Reverse proxy. A domain whose location / is replaced (a reverse-proxy domain or an admin raw location /) ignores the rule, the same as Deny paths and Static cache.

Verification

  • Go tests: internal/frontcontroller, nginxrules, nginximport, api, reconciler and panel-agent/internal/commands all pass.
  • Vitest: src/shells passes (74 files, 329 tests), and tsc is clean.
  • Falsification: 14 Go guards and 3 UI guards. Each guard was disabled in turn, and each made a test fail; none failed only to build.
    • Go: variable allowlist, literal charset, script .., one per domain, tenant subset, API validate, FrontController validate, agent re-validate (before and after the logging refactor), empty → default, template token, reconciler param, import root branch, import default note.
    • UI: picker one-per-domain, import replace, preview line.
  • Test box .60 (branch agent only, because the panel would migrate 308→314). Throwaway user gh1999t and domain gh1999t.test, driven through domain.create with php_fallback:
    • /users/edit/123 → mod=/users/edit/123; /dashboard → mod=/dashboard; /users?page=2&sort=name → mod=/users, with the args passed through.
    • /assets/css/app.css is served directly (body{color:red}).
    • The vhost differs from the reconciler's default render only in the fallback line, and nginx -t passes.
    • An injected /index.php?a=1; return 302 https://evil.test is logged and rendered as the default, byte-identical to the reconciler's render.
    • Cleanup: the throwaway domain and user were deleted and the original agent restored. All 14 vhosts are back to their pre-test hashes.
  • Rebase: rebased onto origin/main (no-op), and tests re-run.

Branch gh1999-front-controller-rule: d29a7decc, a8aa24281.

https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj

shukiv added 2 commits October 5, 2026 00:31
…o file go — GH #1999

An app with its own router (lxsdevcode's admin subdomain) needs the PHP
fallback of the site's location / changed, e.g.
  try_files $uri $uri/ /index.php?mod=$uri&$args;
A raw location / drops the panel's PHP locations (RootOverridden), and a
catch-all rewrite also sends CSS/JS/images to PHP.

New typed Rule Builder kind front_controller (script + query), for admins
and, behind tenant domain options, for owners:
- internal/frontcontroller: the shared grammar. Script is a .php path from
  the docroot (no .. or //); the query takes only [A-Za-z0-9_.-=&/%] literals
  and $args $document_uri $is_args $query_string $request_uri $uri, so the
  fallback stays one nginx token.
- API: validated on save, at most one per domain, in the tenant-safe subset.
- nginxrules.Compile renders nothing for it; FrontController sends the
  fallback to the agent as php_fallback.
- Agent: vhostData.TryFilesFallback re-validates at render time and falls
  back to /index.php?$query_string. With no rule the vhost is byte for byte
  unchanged (tested across vhost shapes). PHP locations are untouched.
- nginx import maps location / { try_files $uri $uri/ <script>?<query>; }
  onto the rule; other root locations stay a security warning.
- UI: Front Controller card with a live try_files preview; the picker hides
  it once a domain has one, and an imported one replaces the existing rule.
- Docs: user Domains page, server settings, nginx runbook, ADR-0169 addendum.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
…and document the cache and reverse-proxy interactions — GH #1999

TryFilesFallback is now pure; writeVhost logs the rejected value once instead
of once per server block. Docs: a non-default query sends routed pages to PHP
with a query string, so they bypass the page cache (fail-closed); a domain
served entirely by a reverse proxy ignores the rule, like Deny paths and
Static cache.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
@shukiv
shukiv merged commit ed39221 into main Oct 4, 2026
11 checks passed
@shukiv
shukiv deleted the gh1999-front-controller-rule branch October 4, 2026 21:41
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.

1 participant