fix(onboarding): say what a domain buys, earn the tick, and write the hardening guide (#2691, #2692) - #2773
Conversation
|
merge-train note — #2715 landed on |
… hardening guide (#2691, #2692) The first-run hardening card offered "Add a domain" with no statement of what it buys or what has to be true first, and then showed a green tick over a value nobody had checked. Walked live on a fresh droplet, nobody in the room could say why an operator would add a domain, or whether the setting changed routing at all. What the setting actually does, now documented in the copy and in requirements/infrastructure.md: it is the address Trinity hands out (Telegram, WhatsApp and VoIP callbacks, Slack's OAuth return, public links, workspace and file links), and on a provisioned host it authorises the web server in front to obtain a certificate for that one name. It does not create a DNS record, and Trinity issues no certificate itself. The tick is now earned. Caddy's on-demand-TLS gate is called during a real handshake for the saved name, which is proof of the whole chain an operator cannot otherwise confirm from inside Trinity — DNS resolves, traffic arrives, a certificate follows — and stays true behind Cloudflare's proxy, a load balancer or a reserved IP, where comparing the name's DNS answer against this instance's own address says the opposite. An authorised ask latches `<iso>|<host>`; the reader compares that host to the one in force, so a stale row reads as not-reached rather than showing a tick for a name nobody visited. Until it flips, both the first-run step and Settings say "saved, waiting for the first visit". Step completion is unchanged, so no established install re-opens the overlay. Only Caddy's own ask latches. The same route is reachable from the public internet — Caddy proxies to the frontend and nginx forwards /api/ — and the domain is published in every webhook URL, so an unguarded latch could be forged with one curl. The guard fails closed: an unrecognised caller is still answered, it just does not record anything. Two live defects fixed alongside: - The gate never IDNA-encoded. SNI is ASCII so Caddy asks about the A-label while an operator saves the name as they read it, so a domain with any non-ASCII character could never obtain a certificate — silently, forever, on every visitor's page load. - The generic settings PUT accepted any string for this key. `htp://typo.com` stored cleanly and the back-fill immediately re-pointed every Telegram webhook and WhatsApp binding at an address that answers nothing. A value that cannot take effect is now refused before the write, with a named error. Plain http:// stays legal — the managed fleet advertises exactly that behind a tunnel. Copy: benefit and prerequisite are readable without opening the disclosure, the sentence claiming the proxy "picks up the name" is gone (it never did), the webhook side effect is stated where the setting is owned, and the tunnel is "optional but recommended" rather than "dismissing it here is a fine answer" — which contradicted the guide it now links to. Settings → General carries the same explanation from the same module, and the http-only regex no longer lets `http://` complete the step it exists to move people off. tunnel, or a private network instead, each stage with a verification step, the marketplace-specific bits the generic public-access page does not cover, and Tailscale framed honestly (operator access, no Trinity integration, not a substitute for the tunnel). Linked from the deploy index, DEPLOYMENT.md and the marketplace listing. Fixes #2691 Fixes #2692 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…rd (#2692) The guide listed Slack events among the integrations a VPN breaks. #2380's own decision table says the opposite, and the code agrees: the live transport is adapters/transports/slack_socket.py, an outbound WebSocket that needs no public URL. Only the one-time OAuth install callback does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…2692) The page was hand-written and followed the feature template. Pages under guides/deploying/ that cover an operation follow the operational one instead: When to Run This → Pre-flight → Procedure → Verify → Recovery, with the verification table and the reusable compose-restart rule stated verbatim rather than paraphrased. Also per the skill's maintenance trigger, the FAQ pages that neighbour this topic gain the questions a user would actually type — how to lock down a marketplace instance, what 'waiting for the first visit' means, and why a domain can show a certificate error while Trinity reports it as set — and faq/README.md is regenerated from the pages' own headings rather than hand-edited. That regeneration also corrects one pre-existing anchor that had drifted from the heading it points at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…this image has (#2692) There is no one-click to lean on, and that is now a checked fact rather than an assumption: DigitalOcean's marketplace has no Tailscale app (its application image list has none), Vultr's API lists none either, and both apply marketplace images at create time only — so nothing can be added to a droplet that is already running. The improvement available is a single non-interactive command with an auth key, which is now written out, along with the two traps that bite later: node keys expire after 180 days by default, which silently drops the machine off the tailnet after port 22 is closed, and --ssh must be opted into per device before that happens. It also records a gap rather than papering over it. The provisioned web server holds certificates for the droplet's public IP and the saved domain, and the gate authorises only that one name, so https://<tailnet-ip> gets no certificate — and the public domain still resolves to the address the operator is about to close off. The private-network path therefore covers shell and agent access today; reaching the web UI over a tailnet needs a recipe nobody has verified on a live droplet yet, and the page says so instead of implying it works. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…hat works (#2692) The private-network path left the operator with shell access and no way into the web UI, and the page did not explain why. It now does: the web server picks a site by the hostname in the request and holds exactly two — the droplet's public IP and the saved domain — so a tailnet address matches neither, Trinity refuses the certificate request (the same refusal that stops the instance being an open certificate requester), and no authority could issue for carrier-grade NAT space anyway. The saved domain does not help either, since public DNS resolves it to the address the operator just closed off. The supported answer today is an SSH tunnel to the local frontend port, which needs no certificate and is unaffected by the container firewall — the same mechanism the ops agent already ships. The page also explains why browsing the container port directly does not work, since that is the next thing anyone tries: container ports are dropped from off-box, and 80/443 work in the public case only because the web server in front is a host process. Serving a private address natively is named as a product improvement rather than dressed up as configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
Every site in the provisioned Caddyfile is matched by hostname, and there are two: the instance's public IP and the domain an admin saved. A tailnet address matches neither, the certificate gate refuses it — correctly, that refusal is what stops the instance requesting certificates for any name pointed at it — and carrier-grade NAT space cannot be validated by a public CA in any case. So an operator who moved onto a VPN and closed 80/443 was left with shell access and no URL to open. The saved domain does not help either: public DNS resolves it to the address they just closed off. PRIVATE_NETWORK_CIDRS (space-separated, empty by default) renders an `@private remote_ip` matcher into the http:// site that serves those sources directly instead of redirecting them. Plain HTTP costs nothing there — the VPN already encrypts the transport, which is the posture the managed fleet runs and that DEPLOYMENT.md already calls finished rather than a compromise. Unset behaves exactly as before. Source address, never the Host header. A header is supplied by the caller, so matching on it would let anyone on the internet send `Host: 100.64.0.1` to port 80 and be served the login page in cleartext, having bypassed the HTTPS redirect — a worse hole than the one being fixed. A source address cannot be forged into a completed TCP handshake. Three more properties, each because the failure mode is a box you cannot reach: 0.0.0.0/0 and ::/0 are refused; anything that is not an address range is dropped with a warning rather than rendered; and the generated file is validated before the reload, since an invalid config stops the web server outright. Applying a change is `--caddy-only`, a phase that re-renders the config alone. The site phase also rewrites FRONTEND_URL and TRINITY_INSTALL_SOURCE, so re-running it to pick up one variable would silently re-stamp a marketplace droplet's provenance as a doc-driven install. Beyond the two tickets this branch carries, added at the user's request after the gap surfaced while documenting the VPN path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…it (#2692) The FAQ answer was written while the fix did not exist and still told operators to tunnel over SSH because Trinity could not serve a private address. It can now. The install-provenance flow's Caddyfile walkthrough and the TLS decision table in DEPLOYMENT.md gain the same setting, with the trade stated where an operator picks the shape: a private network costs every inbound channel, since those need a public URL to call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Le7kBh9PbTNGHPc5bxm3qz
dca6587 to
611372d
Compare
… test (#2691) The feature-flags handler now reads settings_service.is_public_url_reached(), and this hand-rolled stub AttributeErrored on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Le7kBh9PbTNGHPc5bxm3qz
|
merge-train 2026-09-14: not on this train — two findings need your intent, not a mechanical push. Lane C: 1. The Settings → General tick regresses on every non-Caddy install — 2. Riders for the same push (small):
The IDNA compare fix, the PUT validation, the source-address |
- Settings → General: the "waiting for the first visit" state now shows only on provisioned installs (hardening_guide_eligible). Only the provisioned Caddyfile's TLS ask writes the latch, so own-proxy, tunnel and tailnet installs would have waited forever; they keep the saved tick. - start.sh: render the Caddyfile to Caddyfile.new, validate that, and move it over the live file only on success. An invalid file on disk with the unit enabled took the site down on the next restart. New test proves the previous file survives a failed validate. - _is_caddy_ask: docstring and PROV-016 now say the stamp is unforgeable from the public front door, not from inside the Docker network. A loopback source check cannot close that: Caddy reaches the published port from a bridge gateway. Advisory stamp, accepted. - test_2691: monkeypatch ss.db.get_setting_value instead of assigning it on the shared db instance (reload did not undo it). - settings_service: the reached stamp is never cleared; comment said it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Le7kBh9PbTNGHPc5bxm3qz
|
merge-train: not on today's train — rides the next one once these two are answered. Both blockers from the 2026-09-14 review are unchanged on the branch; nothing has been pushed since 15:25Z that day. 1. The tick sentence is false on most installs. 2. An invalid Caddyfile is left on disk. Riders, cheap to fold into the same push: The rest is sound and validated: no new disclosure on the unauthenticated surface, route ordering fine, the IDNA canonicalisation and PUT validation are right, and the hardening guide uses placeholder domains throughout. |
…py-and-hardening-docs # Conflicts: # docs/user-docs/faq/README.md
The tick's client half is correctly wired, but the store action that
parses it had no assertion at all: deleting both
`stores/sessions.js:164` and `:195` left the full 2930-test frontend
suite green, with `publicUrlReached` permanently false, the
`https-domain-reached` copy dead, and the tick this PR exists to earn
never appearing.
Extends the existing `describe('the store fails closed')` harness, which
already pins three sibling flags on this exact code path, with the
fourth field — plus one sequenced test the other four cannot replace:
they each start from a fresh store where every closed value equals the
state default, so they pass with the `catch` block's resets deleted and
cannot tell a reset from an initial value. Earning the tick and then
failing a forced re-read is the only shape that pins the fail-closed
branch itself, and it is the real path — `FirstRunOverlay.vue:378` and
the three `Settings.vue` save handlers all re-read with `force`, which
is when a just-saved domain's tick resolves.
Both mutations now caught (drop the success assignment → 2 red; drop the
catch reset → 1 red); 130 files / 2931 tests pass.
The overlay's own binding (`FirstRunOverlay.vue:217`) is deliberately
left uncovered: `vitest.config.js` is `environment: 'node'` with no DOM,
so driving it needs a mount harness — an author decision, not a
mechanical fix. A `toContain` pin over the SFC text would only restate
the source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
merge-train: a commit was pushed to this branch ( What and whyLane C came back clean on the security surface — The one gap was coverage, not correctness. The tick's client half is correctly wired end to end, but the store action that parses it had no assertion of any kind: deleting both The commit extends the existing One addition beyond the obvious, and why it was neededThe four in-pattern assertions alone did not fully bite. Deleting the Verified: dropping the success assignment → 2 red; dropping the catch reset → 1 red; baseline 130 files / 2931 tests pass. Deliberately NOT done
Two follow-ups for you (no action needed to merge)
On the two earlier train rejectionsBoth are resolved on the current head, and the second one was written against a stale fetch: |
vybe
left a comment
There was a problem hiding this comment.
merge-train: validated on #2820 (batch of 6, full suite green). Lane C — /validate-pr + /review + /cso --diff. Security surface clean: no SSRF (zero new outbound requests), no new unauthenticated route, no disclosure, verified state unforgeable from the public internet (two independent forwarding-header layers), placeholders only, no credential-shaped setting, no require_role("admin"). Six security behaviours mutation-proven. All six prior merge-train findings confirmed resolved by b3e5448. One mechanical commit pushed to this branch (3f3ca5a) covering the earned-tick store seam; two follow-ups (the prefix-length denylist, the under-described body) noted in the comment above.
Summary
Two linked onboarding tickets. Rebased onto
devafter #2715 landed (squashcf74f5001); the branch now carries only this PR's own commits.Walked live on a fresh droplet, nobody in the room could say why an operator would add a domain, what happens when they do, or whether the setting affects routing at all. This answers that in the copy, and stops the step claiming success for something nothing has checked.
What the setting actually does
Traced end to end and now written down (
requirements/infrastructure.md→ PROV-016):--provisionhost it authorises the web server in front to obtain a certificate for that one name.The tick is earned, not typed
The ticket's AC#4 suggests warning when "the hostname does not resolve to this instance". Both reviews landed on the same objection independently, so it is worth recording rather than quietly reinterpreting: a DNS lookup is wrong at the moment it would run. The step's own copy tells the operator to save before DNS settles, and the card's next step is "put your domain on Cloudflare" — a proxied record resolves to Cloudflare, not the droplet. The warning would fire hardest on the posture the product recommends. Same for a reserved IP, a load balancer, or an AAAA-only record.
So the requirement survives and the instrument changed. Caddy's on-demand-TLS gate is already called during a real handshake for the saved name, and that call arriving is proof of the whole chain: DNS resolves, traffic reaches this box, SNI matches, a certificate follows — true through any proxy in between. An authorised ask latches
<iso>|<host>;is_public_url_reached()compares that host to the one in force. Until it flips, the step and Settings both say saved, waiting for the first visit./api/) and the domain is published in every webhook URL, so an unguarded latch was forgeable with onecurl. Fails closed: an unrecognised caller is still answered, it just records nothing.Two live defects found while tracing
canonical_hostpolicy leaf.htp://typo.comstored cleanly and the back-fill then re-pointed every live Telegram and WhatsApp binding at an address that answers nothing. Refused now, before the write, with a named error. Plainhttp://stays legal — the managed fleet advertises exactly that behind a tunnel — and clearing stays legal.Copy
Benefit and prerequisite are readable without opening the disclosure. The sentence claiming the proxy "picks up the name" is gone — it never did. The webhook side effect is stated in Settings, where the setting is owned, and left out of first-run where nobody has channels yet. Settings → General takes the same two lines from the same module, so the explanation can't depend on which surface you arrived through. The
https?regex that lethttp://complete the step is nowhttps-only.One stance change worth flagging: the card called the tunnel "optional, and dismissing it here is a fine answer", which contradicts #2692's "keep a real instance off the open internet". It now reads optional-but-recommended and names what you accept by skipping. Skippable stays skippable — that's the overlay's Skip control and
required: false, not the copy's job.#2692 — the guide
docs/user-docs/guides/deploying/hardening.md: bare IP → domain → tunnel, or a private network instead, each stage with a verification step. It does not restate the Cloudflare dashboard steps (public-access.mdowns those) and covers the marketplace-specific parts that page doesn't:TUNNEL_TOKENplusstart.sh --hostedrather than a compose profile, theufw --force reseton re-provision, and the fact that over a tailnet you reach Caddy on 80/443 but not container ports. Tailscale is framed honestly — operator access, no Trinity integration, and not a substitute for the tunnel, since a VPN breaks every inbound integration (the recorded 2026-09-01 decision). Linked from the deploy index,DEPLOYMENT.md→ Security Recommendations, and the marketplace listing.Docs-site note: I initially reported the deployment pages as unpublished. Wrong — they publish at
/getting-started/deploying/…, and the docs site remaps that folder. The real defect is that they're missing from the site's page registry and so are invisible to site search; filed asabilityai/trinity-docs#1, along with two stale sync-skill routing tables that would have skipped this new page.Tests
test_2691_public_url_reachability.py— 14 tests: the A-label round trip, that canonicalisation doesn't widen the allowlist, the forgery guard (a forwarded request is answered but records nothing), write-once-per-process, never-raises, the host-scoped read including the stale-stamp and cleared-setting cases, and the save guard driven through the real handler rather than asserted against source text.hardeningGuide.spec.js36 tests, full suite 2717 passing. One failure (portalAgentsAtCentre, a date-locale assertion) fails identically on the untouched branch — verified by stashing.check:tokens, both ratchets, andvite buildclean.Test plan
pytest tests/unit/test_2691_public_url_reachability.py tests/unit/test_2380_*.pycurl https://<ip>/api/public/tls-allowed?domain=<saved>→ 200, and the tick does not appear.Not fixed here
Clearing the Public URL leaves the gate authorising a host baked into
PUBLIC_CHAT_URLin the environment, so Settings reads "not configured" while certificates are still issued. Pre-existing; the guide now says so.Fixes #2691
Fixes #2692
🤖 Generated with Claude Code
https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3