Skip to content

feat: add trusted public origins and managed Caddy mode#134

Merged
wesm merged 13 commits intowesm:mainfrom
tpn:133-managed-caddy-public-access
Mar 12, 2026
Merged

feat: add trusted public origins and managed Caddy mode#134
wesm merged 13 commits intowesm:mainfrom
tpn:133-managed-caddy-public-access

Conversation

@tpn
Copy link
Copy Markdown
Contributor

@tpn tpn commented Mar 10, 2026

Closes #133.

Adds two related pieces for remote/hostname access:

  • explicit trusted public origin support (public_url / public_origins)
  • an optional managed Caddy sidecar for TLS-terminated access with CIDR allowlists

This keeps the existing DNS-rebinding and CSRF protections intact while making common remote access setups much easier to configure.

Design

Explicit trusted origins instead of wildcard host/origin relaxations: The backend still defaults to loopback-only origin/host allowlists. When public_url or public_origins are configured, the server derives additional trusted Host and Origin values from those explicit inputs rather than weakening the protections globally.

Managed proxy is opt-in and keeps the backend on loopback: In managed Caddy mode, agentsview validates that the backend bind host is loopback-only, writes a generated Caddyfile under ~/.agentsview/managed-caddy/, runs caddy validate, then launches caddy run as a child process. This keeps the CIDR allowlist meaningful because the backend is not exposed directly.

Explicit public bind and port: Managed Caddy now has separate backend and frontend settings. -host / -port remain the backend listener, while proxy.bind_host / public_port control the public-facing socket. The managed default public port is 8443.

CIDR allowlists with shorthand normalization: allowed_subnets / --allowed-subnet accept repeated CIDRs and normalize shorthand IPv4 input like 10.0/16 to 10.0.0.0/16.

Scope

  • public_url / --public-url
  • public_origins / --public-origin
  • managed caddy proxy mode
  • proxy.bind_host / --proxy-bind-host
  • proxy.public_port / --public-port
  • allowed_subnets / --allowed-subnet
  • README examples for direct hostname access and managed Caddy usage

Not implemented

  • automatic Caddy installation
  • ACME / cert provisioning
  • service management / daemonization

The patch assumes the caddy CLI is already installed and available either on PATH or via --caddy-bin.

Platform notes

Managed Caddy mode is designed to work anywhere the caddy CLI works. That includes Linux, macOS, and Windows in principle, but this PR intentionally keeps Caddy installation/packaging out of scope.

Changes

  • Config/CLI: add public_url, managed proxy settings, CIDR allowlists, and public origin normalization/validation
  • Server: derive trusted hosts/origins from configured public origins instead of only the bind address
  • Managed Caddy: generate config, validate, supervise child process, and keep the backend loopback-only
  • Docs: add examples for direct remote access and managed Caddy usage, plus explicit note that Caddy installation is external
  • Tests: add config, handler, and managed-Caddy unit tests for origin handling, bind/public port behavior, and CIDR validation

Test plan

  • Full Go suite passes (CC=gcc CXX=g++ make test)
  • Manual: build repo-local binary and hit GET /api/v1/version through managed Caddy over TLS with curl --resolve
  • Frontend vitest / Playwright were not run separately for this PR

🤖 Generated with [Codex]

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 10, 2026

roborev: Combined Review (4c0e07a)

The PR successfully implements public URL support
and a managed Caddy reverse proxy, but introduces two medium-severity regressions related to port negotiation and wildcard host readiness probes.

Medium

  • Inconsistent port in public_url mode: In [cmd/agentsview/main.go](/home/roborev/.roborev/cl
    ones/wesm/agentsview/cmd/agentsview/main.go) (lines 205-221, 264-266), FindAvailablePort can change cfg.Port, but cfg.PublicURL and the merged cfg.PublicOrigins keep the original port from config. The process then advertises and opens the stale public URL while the backend actually listens on a different port. In the direct-hostname case, this means remote access breaks and the trusted origin points to the wrong service. Suggested fix: If public_url is set without
    a managed proxy, either fail fast when the requested port is unavailable or rewrite PublicURL/PublicOrigins after the final port is chosen.
  • Readiness probe dials wildcard bind addresses directly: In [cmd/agentsview/main.go](/home/roborev/.roborev/cl
    ones/wesm/agentsview/cmd/agentsview/main.go) (lines 241-246) and [cmd/agentsview/managed_caddy.go](/home/roborev/.roborev/clones/wesm/agentsview/cmd/agents
    view/managed_caddy.go) (lines 249-261), waitForLocalPort uses cfg.Host verbatim. Passing -host 0.0.0.0 or -host :: triggers a probe against 0.0.0.0:port /
    [::]:port, which is not a reliable client target and can fail even when the server is listening correctly. Suggested fix: When binding all interfaces, probe a loopback/local-interface address instead, or replace the dialback with a listener-based readiness signal.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 10, 2026

Addressed both roborev-ci findings in c71986e.

  • Fixed the stale public_url / trusted-origin case when the backend port is auto-relocated and the configured public URL was clearly tied to that original backend port. Manual external-proxy URLs on other ports are left unchanged.
  • Fixed readiness probing for wildcard backend binds by mapping 0.0.0.0 -> 127.0.0.1 and :: -> ::1 before dialing.
  • Added unit coverage for both behaviors and reran the full Go suite: CC=gcc CXX=g++ make test.

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 10, 2026

@wesm this is fun; it's all agent-driven on my side (apart from this message, which has been delicately crafted by a meatbag mashing keys, OG-style), and clearly all agent-driven on your side. Wild new frontier we're in.

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 10, 2026

FWIW this is how I'm using it on my headless DGX Spark, with TLS set up so that I can view everything on any other host on my home network:

/home/trent/src/agentsview/agentsview \
    -host 127.0.0.1 \
    -port 8080 \
    -public-url https://spark.trent.me \
    -proxy caddy \
    -proxy-bind-host 0.0.0.0 \
    -public-port 8443 \
    -tls-cert /home/trent/.certs/trent.me-server-bundle.crt \
    -tls-key /home/trent/.certs/trent.me-server.key.decrypted \
    -allowed-subnet 10.0/16 \
    -no-browser

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 10, 2026

roborev: Combined Review (c71986e)

Summary: The PR implements public origin and managed Caddy support, but introduces a high-severity network exposure risk and a medium-severity startup race condition.

High

  • Unrestricted
    Network Exposure via Managed Caddy

    Managed Caddy defaults to binding 0.0.0.0, and the generated Caddyfile is unrestricted unless allowed_subnets is explicitly set. In practice, enabling -proxy caddy publishes the full UI/API to any client that can reach
    the host on the public port, while the application endpoints remain unauthenticated by design. That turns a local session viewer into a network-reachable service exposing session contents and allowing state-changing operations such as sync/upload/publish.
    Locations: [internal/config/config.go](/home/roborev/.
    roborev/clones/wesm/agentsview/internal/config/config.go), cmd/agentsview/managed_caddy.go
    Suggested Remediation: Default managed Caddy to loopback (127.0.0.1) and require an explicit opt
    -in before binding non-loopback addresses. If non-loopback binding is requested, require at least one access-control mechanism such as allowed_subnets, mTLS, or another explicit front-door restriction.

Medium

  • False Readiness Banner and Startup Race Condition
    The new startup flow
    prints the success banner before either the backend or managed Caddy is actually confirmed ready, then waits in waitForLocalPort() without watching serveErrCh or cancellation. If ListenAndServe() fails immediately after FindAvailablePort() (port race, bind error, etc.), or the user interrupts during startup, the
    process can show a false “listening” message and then sit in the 5-second probe loop instead of surfacing the real failure promptly.
    Locations: main.go, [main.go](/tmp
    /agentsview-review/cmd/agentsview/main.go#L257), managed_caddy.go
    Suggested Remediation: Race the readiness wait against
    serveErrCh and ctx.Done(), and only print the startup banner after backend/Caddy readiness has been established. Add test coverage for immediate backend-start failures or interrupted startups.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 11, 2026

Addressed the latest roborev-ci findings in dedb134.

  • Hardened managed Caddy defaults: the generated proxy now defaults to loopback bind (127.0.0.1) instead of 0.0.0.0. Non-loopback binds now require at least one allowed_subnet, so exposing the UI/API over the network is an explicit opt-in with an access-control gate.
  • Reworked startup readiness sequencing: the success banner and browser-open now happen only after backend readiness is confirmed, and (when enabled) after the managed Caddy frontend is confirmed ready too. The readiness wait now races against backend/proxy startup failures and ctx.Done() instead of sleeping the full timeout blindly.
  • Added unit coverage for the non-loopback allowlist requirement and the readiness wait error/cancellation paths.
  • Reran the full Go suite: CC=gcc CXX=g++ make test.

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 11, 2026

roborev: Combined Review (dedb134)

**
Summary Verdict:** The PR is mostly clean and secure, but contains one medium-severity issue regarding the managed Caddy shutdown process.

Findings

Medium

  • Managed Caddy shutdown race condition: Normal shutdown can be misreported as a fatal managed-Caddy failure. runServe races caddyErr Ch against ctx.Done() (cmd/agentsview/main.go, diff lines 236-299 and 328-357), while startManagedCaddy ties the Caddy process directly to the same signal-driven context (cmd/agentsview/ managed_caddy.go, diff lines 216-247 and 320-352). On SIGINT/SIGTERM, Caddy may exit and publish an error before the ctx.Done() branch wins, which turns an expected shutdown into fatal("managed caddy error: ..."). The same race exists during the startup readiness wait.
    • Suggested fix: Treat Caddy exit as expected when ctx.Err() != nil, or give Caddy its own cancelable context and only cancel it from the explicit shutdown path. A regression test should cover "
      signal arrives while caddy.Err() is also ready".

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 11, 2026

Addressed the latest managed-Caddy shutdown race in 51b4d05.

  • Managed Caddy now runs under its own cancelable context instead of being tied directly to the signal context, so shutdown does not race an automatic child-process cancellation against the main select loop.
  • The readiness wait now prefers ctx.Done() over an already-ready error channel, so signal-driven shutdown is treated as cancellation rather than a startup/proxy failure.
  • The main loop now treats a Caddy exit as expected when shutdown is already in progress instead of fataling on a normal stop path.
  • Added a regression test covering the cancellation-vs-error race in the readiness helper.
  • Reran the full Go suite: CC=gcc CXX=g++ make test.

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 11, 2026

roborev: Combined Review (51b4d05)

The PR successfully implements public_url and managed Caddy support, but there is one medium-severity issue regarding broadened host trust to address.

Medium

  • [internal/server/server.go](/home
    /roborev/.roborev/clones/wesm/agentsview/internal/server/server.go)
    (in Handler / buildAllowedHosts): public_origins is now used to expand the accepted Host header set, not just the CORS origin set. That
    means an origin added only to permit cross-origin browser requests also becomes a trusted host for the DNS-rebinding check. The README describes public_origins as an advanced browser-origin override, so this widens trust more than advertised. Suggested fix: keep public_origins in buildAllowedOrigins only, and derive extra allowed hosts from public_url (or from a separate explicit host list). Add a test proving a CORS-only origin does not bypass the Host check.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 11, 2026

Addressed the latest host-trust finding in 1eefa65.

  • Narrowed the trust boundary so public_origins affects CORS only. It no longer expands the accepted Host header set used by the DNS-rebinding defense.
  • Extra trusted hosts now come only from public_url, which matches the documented meaning of public_origins as an advanced browser-origin override.
  • Added a regression test proving that a CORS-only origin does not bypass the Host check, while a configured public_url still permits the expected host.
  • Reran the full Go suite: CC=gcc CXX=g++ make test.

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 11, 2026

roborev: Combined Review (1eefa65)

Summary Verdict: The PR successfully introduces a managed Caddy reverse proxy mode for secure remote access, but requires a fix for a medium-severity bug regarding host-header matching with default ports.

Medium

  • internal/server/server.go
    , internal/config/config.go

    Managed Caddy preserves explicit default ports in PublicURL (https://host:443, http://host:80), but host allowlisting only trusts the exact u.Host when a port is present. That means a valid config like -public-url https://viewer. example.test:443 -proxy caddy can fail same-origin API requests if the browser sends Host: viewer.example.test without :443. PublicOrigins is normalized to the portless form, so CORS can pass while hostCheckMiddleware still returns 4

  1. Suggested fix: either normalize default ports out of managed PublicURL in resolvePublicURL, or make addHostHeadersFromOrigin also add the portless host when the explicit port equals the scheme default. Add a regression test for explicit :443/:80 public URLs
    .

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 12, 2026

Addressed the latest default-port host-matching issue in cf8d5c2.

  • Managed public_url normalization now strips explicit default ports, so https://host:443 becomes https://host and http://host:80 becomes http://host.
  • That keeps the configured public URL aligned with what browsers actually send in the Host header for default ports, which avoids the same-origin/host-check mismatch noted in the review.
  • Added regression coverage for explicit managed :443 and :80 public URLs.
  • Reran the full Go suite: CC=gcc CXX=g++ make test.

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 12, 2026

Added a small README note in 2b7ceec covering privileged ports for managed Caddy on Linux.

It now recommends keeping agentsview unprivileged and using setcap cap_net_bind_service=+ep on the caddy binary if the user wants -public-port 443 or 80, rather than running the whole viewer as root. It also explicitly points users back to the default 8443 path when they do not need a privileged port.

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 12, 2026

roborev: Combined Review (2b7ceec)

Summary Verdict: Introduces a managed Caddy reverse proxy mode with remote access capabilities, but requires
fixes for Caddyfile injection and a browser startup readiness issue.

Medium

  • Caddyfile Injection via Unvalidated bind_host

    • Locations: [internal/config/config.go#L520](/home/roborev/.roborev/cl
      ones/wesm/agentsview/internal/config/config.go#L520), [cmd/agentsview/managed_caddy.go#L255](/home/roborev/.roborev/clones/wesm/agentsview/cmd/agentsview/
      managed_caddy.go#L255)
    • proxy.bind_host / -proxy-bind-host is only whitespace-trimmed, then written verbatim into the generated Caddyfile via bind %s. This field is not constrained to a single safe token. A value
      containing spaces or newlines can inject additional Caddy directives into the managed config, which can disable the intended remote_ip restriction or change how the proxy is exposed.
    • Suggested Fix: Validate bind_host as a single IP literal or strict hostname before writing it, and reject any
      value containing whitespace or control characters. Stronger option: stop generating a raw Caddyfile with string interpolation and emit a structured Caddy JSON config instead.
  • Browser Startup Readiness Probe Uses Public HTTPS URL

    • Locations: [main.go#L319](/home/roborev
      /.roborev/clones/wesm/agentsview/cmd/agentsview/main.go#L319), [main.go#L600](/home/roborev/.roborev/clones/wesm/agentsview/cmd/agentsview/main.
      go#L600)
    • Browser startup is routed through openBrowser(publicURL), and openBrowser probes readiness with http.Get(url + "/api/v1/stats"). For the new managed-Caddy / reverse-proxy flow, publicURL can
      be HTTPS with a user-supplied cert and custom hostname. In that case, the probe can fail on TLS trust or DNS even though the backend and Caddy are already ready, causing startup to wait out the retry loop.
    • Suggested Fix: Keep browser-launch readiness tied to a local TCP/HTTP
      probe (waitForLocalPort, backend URL, or proxy bind host/port) instead of issuing an HTTPS GET against the public URL.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@wesm
Copy link
Copy Markdown
Owner

wesm commented Mar 12, 2026

thanks @tpn, let me have a go at this and I'll get it merged soon?

@tpn
Copy link
Copy Markdown
Contributor Author

tpn commented Mar 12, 2026

All yours! (If nothing else, this has motivated me to set up roborev locally!)

@wesm wesm force-pushed the 133-managed-caddy-public-access branch from dbff624 to a7029fd Compare March 12, 2026 01:48
@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented Mar 12, 2026

roborev: Combined Review (dbff624)

Verdict: The pull request introduces remote access capabilities and managed proxy modes, but requires important security and configuration fixes prior to merging.

High

  • Missing Authentication for Remote Access
    Files
    : internal/server/server.go, [internal/server/openers.go](/home/roborev/.roborev/clones/wesm/agents
    view/internal/server/openers.go), internal/server/resume.go, [internal/server/export.go](/home/roborev/.
    roborev/clones/wesm/agentsview/internal/server/export.go), README.md
    These commits add documented non-loopback/remote-access modes
    , but the API still has no real authentication and relies only on Host/Origin checks. Those headers are browser hints, not an access-control mechanism; any client that can reach the port can send Host: viewer.example.test and Origin: https://viewer.example.test directly. In remote mode that exposes sensitive endpoints including POST /api/v1/sessions/{id}/resume (launches agent CLIs), POST /api/v1/sessions/{id}/open (launches local programs), POST /api/v1/config/github (
    replaces stored GitHub token), and POST /api/v1/sessions/{id}/publish (exfiltrates session data). managed_caddy only optionally narrows by source subnet, and the README explicitly documents direct -host 0.0.0.0 exposure, so the old localhost
    -only trust model no longer holds.
    Suggested remediation: Require real authentication whenever public_url enables non-loopback access, ideally before any API route. A random bearer token, reverse-proxy auth, or mTLS would work; at minimum, refuse direct non-loopback mode without an
    auth layer and disable resume/open/config-mutating endpoints unless the backend remains loopback-only behind an authenticated proxy.

Medium

  • Incorrect Port Rewriting for Public URLs
    Files: [managed_caddy.go](/tmp/agentsview-review-dbff
    624/cmd/agentsview/managed_caddy.go#L51) / main.go
    rewriteConfiguredPublicURLPort() rewrites a portless public_url whenever its scheme default port matches the requested backend port. That breaks a valid “own reverse proxy on 80/443” setup if the backend port is occupied and falls back. Example: -port 443 -public-url https://viewer .example.test becomes https://viewer.example.test:444, even though the external proxy is still on 443.
    Suggested fix: Only rewrite when the user explicitly specified the port in public_url; do not infer coupling from an omitted default port.

    Testing gap: Current coverage only preserves proxy ports for 8080 -> 8081, not the 80/443 default-port case.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

The guidelines said "LOCAL-ONLY" and "NO AUTHENTICATION NEEDED"
unconditionally, which caused false positives now that the branch
adds a documented non-loopback proxy mode. Updated guidelines 1,
3-5 to describe the proxy security model (loopback backend + Caddy
subnet filtering) and added guideline 17 covering the managed
Caddy architecture invariants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merge related guidelines (auth+proxy, desktop 14/15/16, schema
7/12), cut verbose examples, trim explanatory prose. Same coverage
in roughly half the lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wesm wesm merged commit 7ab086e into wesm:main Mar 12, 2026
5 of 6 checks passed
@wesm
Copy link
Copy Markdown
Owner

wesm commented Mar 12, 2026

thanks @tpn!

@tpn tpn deleted the 133-managed-caddy-public-access branch March 12, 2026 02:22
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.

Managed Caddy mode for remote/TLS access

2 participants