feat(proxy): cover all remaining services with dream-proxy subdomain#1366
feat(proxy): cover all remaining services with dream-proxy subdomain#1366y-coffee-dev wants to merge 8 commits into
Conversation
Lightheartdevs
left a comment
There was a problem hiding this comment.
Thanks for the thorough proxy plumbing here. The resolver/test shape is good, and dream-server/tests/test-proxy-config.sh passes locally for me. I’m requesting changes because this currently turns several previously loopback-style/raw service APIs into LAN-reachable proxy routes without an auth/exposure policy, and the new dashboard/summary URLs also break when the supported proxy port override is used.
Findings:
-
[P1]
dream-server/scripts/resolve-proxy-config.sh:160generates plainreverse_proxyfragments for every manifest withproxy.subdomain, whiledream-server/extensions/services/dream-proxy/compose.yaml:37publishes the proxy on${DREAM_PROXY_BIND:-0.0.0.0}. This PR adds proxy blocks to raw services such asllama-server(extensions/services/llama-server/manifest.yaml:24),embeddings,whisper,tts,searxng, andqdrant. Enabling the optional proxy would therefore expose endpoints likellm.<device>.local/v1andqdrant.<device>.localto the LAN even though those backends do not all have their own auth checks. Please add per-route auth, an explicit manifest exposure classification, or a separate opt-in for unauthenticated API routes before routing these by default. -
[P2]
dream-server/extensions/services/dashboard-api/main.py:1412anddream-server/installers/phases/13-summary.sh:139-151omitDREAM_PROXY_PORT. The schema/compose support non-80 proxy ports, but the sidebar and installer summary always renderhttp://<sub>.<device>.localwithout:<port>. WithDREAM_PROXY_PORT=8080, those links point at port 80 instead of the running proxy.
Validation run locally: bash dream-server/tests/test-proxy-config.sh passes (19/19). The blocker is policy/URL correctness, not the basic resolver mechanics.
|
I did a second pass thinking about this less as “is the current patch mergeable?” and more as “what would make this the canonical answer to the LAN/headless-server access request?” I think the answer is: this PR has the right architecture and most of the useful plumbing; it just needs a small policy layer so we preserve Dream Server’s existing trust boundary. What I would keep from this PR:
The design wrinkle is that the repo already treats direct service ports as loopback-first. A concrete path I’d be happy with:
proxy:
subdomain: comfy
exposure: developer-api # user | developer-api | internal
auth: service # service | dream-session | none
client_max_body: 200MB
Possible env shape: DREAM_PROXY_PROFILE=user # default: user-facing routes only
# DREAM_PROXY_PROFILE=developer # includes developer-api routes
# DREAM_PROXY_PROFILE=all # includes internal routes, loud warningOr split flags are fine too. The important part is that raw APIs like
I want to emphasize: I think this PR is very close to the right answer for the community request. The generated-router approach is good. The change I’m asking for is not a rewrite; it’s making the route generator aware of the trust model that already exists in the repo. |
…routes dream-proxy already fronted chat/dashboard/auth/api/hermes/talk, but ~16 other services (comfy/n8n/litellm/searxng/qdrant/llm/tts/stt/etc.) still exposed individual host ports. Extend the proxy to cover them via the same subdomain-per-service pattern, driven by a new optional `proxy:` block in each extension manifest. A new resolver (scripts/resolve-proxy-config.sh) walks enabled manifests and emits one Caddy fragment per service into data/dream-proxy/sites.d/, which the Caddyfile glob-imports. dream-mdns.py reads the same manifest field so the .local hostnames auto-announce; audit-extensions.py validates the schema (reserved names, charset, collisions). DREAM_PROXY_EXCLUSIVE=true generates docker-compose.proxy-exclusive.yml that strips host-port bindings from every routed service, collapsing the host surface to just :80. Default is off so existing :PORT URLs keep working; resolve-compose-stack.sh picks up the overlay when the flag is on. Dashboard surface follows: /api/external-links emits a proxy_url field when dream-proxy is enabled, and the Sidebar prefers it over the port URL. The installer summary phase prints proxy hostnames when proxy is on.
Classify each proxied service so the resolver can apply Dream Server's existing "one safe LAN entrypoint" trust model. Two new fields per `proxy:` block: exposure: user | developer-api | internal auth: service | dream-session | none User-facing UIs with their own auth (chat, n8n, langfuse, etc.) keep `exposure: user`. Raw OpenAI-shaped APIs (llama-server, embeddings, STT, TTS), the vector DB (qdrant), and bot-abusable web surfaces (searxng, brave-search) become `developer-api` so they stay off the LAN under the default profile. litellm has its own master key but still ships as `developer-api` to avoid surprising users. This is metadata only; the resolver still emits every fragment until the profile-aware filter lands.
Before this commit the resolver emitted a Caddy fragment for every
manifest with a `proxy:` block, regardless of whether the underlying
backend had any auth. Enabling dream-proxy therefore turned the LAN
into a back-door for raw APIs (llama-server /v1, qdrant REST,
embeddings, STT/TTS, search) — the opposite of "one safe LAN entrypoint."
Add a profile filter in scripts/resolve-proxy-config.sh:
DREAM_PROXY_PROFILE=user (default — only `exposure: user`)
DREAM_PROXY_PROFILE=developer (adds `exposure: developer-api`)
DREAM_PROXY_PROFILE=all (adds `exposure: internal`, prints WARN)
Safety rails:
- Manifests with no `exposure` field default to `developer-api`, so a
third-party extension without the new schema doesn't auto-LAN.
- `exposure: user` + `auth: none` exits non-zero unless
DREAM_PROXY_ALLOW_UNAUTHENTICATED_USER=true is set — keeps an
unauthenticated UI from sneaking onto the LAN by accident.
- Duplicate/reserved-subdomain checks fire before the profile filter
so an admin can't mask a mis-configured manifest by switching
profiles.
Audit script gets matching static checks for `exposure` / `auth`
values and warns on the `exposure=user + auth=none` combo.
Existing test fixtures updated to declare exposure/auth so the 19
mechanics assertions stay green; policy assertions land in the next
test commit.
Routes with `auth: dream-session` in their manifest now get the same
Caddy fragment shape as hermes-proxy/Caddyfile: a literal-order
`route { ... }` block with @health public, a forward_auth sub-request
to {$DREAM_AUTH_UPSTREAM:dream-dashboard-api:3002}/api/auth/verify-session,
websocket upgrade headers stripped from the auth sub-request, and a
303 bounce to /auth/required on 401/403.
`auth: service` and `auth: none` keep the bare reverse_proxy fragment.
No service in this PR opts into dream-session yet — hermes still
ships its own hand-written Caddyfile and the rest of the cohort gate
via their own session cookies. The machinery is here so a future
manifest can flip one field and inherit the audited auth pattern,
which is what the maintainer asked for in round 1.
`DREAM_PROXY_PORT` was settable in the schema but never threaded into the URLs the dashboard sidebar (`/api/external-links`) and installer summary render. Setting `DREAM_PROXY_PORT=8080` silently produced links like `http://chat.dream.local`, which the browser resolved to port 80 — the proxy was actually on 8080, so every link 404'd. Read the port out of the env (default 80) and append `:<port>` only when it's non-default, so the common case stays `http://chat.<device>.local` without the port suffix. While we're rewriting the summary banner: also honor DREAM_PROXY_PROFILE so the printed list matches what the resolver actually emits — the default `user` profile no longer advertises `http://llm.<device>.local` etc., because the resolver would have skipped those routes. The developer-api URLs reappear under `DREAM_PROXY_PROFILE=developer`.
Extend tests/test-proxy-config.sh from 19 to 42 assertions. The new
block builds a second fixture tree to exercise the policy layer in
isolation from the EXCLUSIVE overlay tests:
- default `user` profile emits only exposure=user; devapi (developer-api)
and internal fragments stay absent
- `developer` profile adds developer-api routes; internal still absent
- `all` profile adds internal routes
- exposure=user + auth=none → resolver exits non-zero unless
DREAM_PROXY_ALLOW_UNAUTHENTICATED_USER=true
- auth=dream-session fragments include forward_auth, the verify-session
URI, the /health public matcher, the WS-upgrade header strips, and
the 303 bounce to /auth/required
- duplicate / reserved subdomain rejections fire regardless of profile
- DREAM_PROXY_EXCLUSIVE=true overlays only the routes the active
profile actually emitted (caught the earlier behavior where all
proxied services were stripped even when the resolver wouldn't
route them)
Add two dashboard-api pytest cases covering /api/external-links port
rendering: DREAM_PROXY_PORT=80 keeps the bare hostname; =8080 appends
:8080 so the link actually resolves to the proxy.
Add a "Profiles and exposure classification" section to DREAM-PROXY.md explaining the new `exposure`/`auth` manifest fields, the `DREAM_PROXY_PROFILE` env var, and which profile routes which tier. Add an "Off-LAN access" subsection that points operators at Tailscale / WireGuard / oauth2-proxy instead of publishing port 80 directly. MDNS.md gets two short notes: URLs reflect `DREAM_PROXY_PORT` when it's not 80, and extension subdomains are gated by profile (cross- reference to DREAM-PROXY.md for the table).
e762bd9 to
2ac7a22
Compare
Original classification was guessed from service category instead of
actually reading each service's source. Re-audited compose env vars
and FastAPI route declarations:
comfyui user/service → user/dream-session
ComfyUI ships no built-in auth; bare /listen 0.0.0.0
on port 8188. LAN access would let anyone run image
gen, browse outputs, and install custom nodes (which
execute arbitrary Python).
perplexica user/service → user/dream-session
Web UI has no login — only LITELLM_KEY for the
upstream LLM call. LAN drive-by burns your tokens.
token-spy user/service → user/dream-session
/api/* and /v1/* are gated by TOKEN_SPY_API_KEY but
the /dashboard HTML is open. The chrome leaks.
ape user/service → developer-api/service
No UI — pure API (/verify, /approve, /audit, /policy,
/metrics), all gated by APE_API_KEY.
privacy-shield user/service → developer-api/service
No UI — proxy.py is a /{path:path} catch-all gated by
SHIELD_API_KEY.
n8n (admin password), langfuse (init user + .disabled by default),
litellm (master key), openclaw (gateway token) were classified
correctly the first time.
Net effect under the default `user` profile: ape and privacy-shield
no longer LAN-route (they were never UI-shaped to begin with);
comfyui / perplexica / token-spy now go through forward_auth so a LAN
visitor has to redeem a dream-session before reaching them.
|
Thanks for the review! you're right that the bare PR turned every backend into a LAN endpoint. Rebased onto current main and pushed seven follow-up commits:
|
Codex current-main auditI re-audited this after the exposure/auth/profile follow-ups against current What I verified locally:
I would not merge yet because the new/changed proxy routing contract is stale and failing:
Recommended fix:
Bottom line: good feature direction, likely valuable, but please don?t merge while its own routing contract is wrong/failing. |
feat: Extend dream-proxy to every shipped service
dream-proxyalready frontedchat/dashboard/auth/api/hermes/talkon:80. The other ~16 services still bound their own host ports and surfaced ashttp://localhost:<PORT>URLs in the installer summary. This PR brings them under the same subdomain-per-service pattern, driven by a new optionalproxy:block in each extension manifest.What lands
Manifest schema. Each extension manifest can declare a
proxy:block:Validated by scripts/audit-extensions.py DNS-safe charset, no use of reserved names (
chat,dashboard,auth,api,hermes,talk,root,www), and no two extensions claim the same subdomain.Resolver. scripts/resolve-proxy-config.sh walks enabled manifests at stack-bring-up time and writes one Caddy fragment per service into
data/dream-proxy/sites.d/; the existing Caddyfile glob-imports them. Idempotent and sweeps stale fragments.mDNS auto-announce. bin/dream-mdns.py reads the same
proxy.subdomainfield, so new extensions auto-publish without code changes. The required-subdomains contract test was updated to trackcore_subdomain_routes.EXCLUSIVE toggle.
DREAM_PROXY_EXCLUSIVE=truegenerates adocker-compose.proxy-exclusive.ymloverlay that drops every routed service's host-port binding viaports: !reset null(Compose 2.20+ override tag — plainports: []silently merges with the base list, found that bug during live testing). scripts/resolve-compose-stack.sh picks the overlay up when the flag is on.Dashboard surface.
/api/external-linksadds aproxy_urlfield when dream-proxy is enabled; the Sidebar prefers it over the port URL. Installer phase 13 prints proxy hostnames as the primary URLs and tucks direct ports under a "developers" subheading.Subdomain map
llmembedlitellmn8nstttokensttslangfusecomfyshieldapeperplexicaopenclawbravesearxngqdrantTests
!reset nullto plain[]).make lint,make test,audit-extensions.py --strict,test_mdns_subdomains.py— all green.Live LAN verification
Exercised on two real machines
dream-1anddream-2caddy validateagainst the merged Caddyfile + 6 generated fragments → "Valid configuration"dream-proxyboots; all 11 routes (6 mine + 5 core) reach their named upstreamsavahi-resolve-host-name llm.dream-2.localresolves to the correct IP, thencurl http://llm.dream-2.local/responds with200DREAM_PROXY_EXCLUSIVE=trueoverlay:docker compose configshows every routed service'sportscollapse from[{...:4000...}]toNone.Notes
dream-proxyiscategory: optional, operators still rundream enable dream-proxyto activate..localon bare Linux clients:libnss-mdnsships withmdns4_minimalwhich only resolves single-label.local. macOS / iOS / Windows 10+ / Linux-with-systemd-resolved all resolve multi-label natively (verified). Older Linux setups need/etc/mdns.allowormdns4(non-minimal) innsswitch.conf.