Repository navigation
Allocation write seam: tailnet path from an attested proxy ingress plus serve-status readback; mount POST /allocations and /allocations/{id}/release - #13271
Conversation
…; mount POST /allocations and /allocations/{id}/release
roadmap_serve_request_security hard-coded ServeStatusUnreadable / no route, so
AccessPathTailnetProxy was unconstructible and the allocation writes stayed
unmounted. The seam now consumes a serve-route readback:
- extdeps.tailscale.serve tailscale_serve_status_route_proxy_targets projects
the status document at a route key (listener x mount), not a mount across
every listener.
- gunbc.auth.tailnet_serve_readback: the timed `tailscale serve status --json`
read and the standing fold. No declared endpoint, no reading, a stale or
undated reading, an unreadable document, or another backend at the declared
route all yield no route.
- gunbc.roadmap_serve: the declared route is the desired-deployment roster's
endpoint for the instance; the reading is taken only for a selected route
whose write law requires the tailnet path.
- gunbc.workspace_allocation_routes mounts create and release under
tailnet_path_required: true, with expiry, image digest and public key
reference from server policy.
Witnesses: floor claims over the seam's access path (missing route, mismatched
route, header without reading, malformed identity, stale reading, CSRF,
browser-supplied terms, mounted routes, positive control) and one wet claim in
which the admitted create and release land in a file store.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ts and derives unavailable from the readback Per the parent ruling there is one mount, owned by the write-seam lane. The client keeps only rendering the server's typed answer (503 availability/reason/cause, agreed with #13271); the wire word is gunbc.workspace_allocation_wire workspace_write_unavailable_wire. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ble with cause
A tailnet header no reading vouched for is now AccessPathTailnetUnobserved
carrying a TailnetRouteGap, one arm per cause: no declared route, reading not
taken (not demanded, clock unreadable, not spawned, command failed with its
exit status and text), stale, unreadable document, route not proxied, route
proxying elsewhere. A serving principal that cannot read the serve config is
therefore told apart from a route that is not installed.
The allocation write routes answer a signed-in request on that path with 503
{"availability": "unavailable", "reason", "cause"}, derived from the request's
own access path. An unauthenticated request is answered 401 and is not told the
host's standing; no header and a direct bind stay 403 from the write law.
request_access_path_of keeps its signature and delegates to
request_access_path_of_standing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dcard arm over the closed handler type The floor refused roadmap_serve_selection_requires_tailnet_path as an unrostered non-fold residue site. The predicate now compares the selected handler to the two allocation write handlers, so there is no wildcard arm and no residue row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ient; name #13271 as the unavailable producer Review 75677: the GET route rows and the create/list/get admission route_path now read gunbc.roadmap_allocation_location; workspace_write_unavailable_wire states its producer and trigger. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
briansrls
left a comment
There was a problem hiding this comment.
HOLD at exact head 7a91e3b2261cced0358ad4a54d64b2da69e43e06.
The route-keyed status projection, exact backend comparison, freshness bound, typed refusal causes, mounted create/release routes, server-owned expiry/image/key reference, and derived 503 response are coherent. Exact-head required CI is green. I found one P1 authority-boundary defect.
tailnet_route_standing proves a node-level configuration fact: Tailscale Serve currently maps the declared listener × mount to this loopback backend. request_access_path_of_standing then combines that global fact with any nonempty Tailscale-User-Login header on a loopback-bound request and constructs AccessPathTailnetProxy. Nothing consumed there proves that this particular connection traversed Tailscale Serve.
That matters because the production backend is a normal 127.0.0.1:8080 TCP listener. A co-located process can connect directly and supply the same header. The repo's existing extdeps.tailscale.identity authority states exactly this: the header is trustworthy only when the proxy is the sole route to the backend; loopback merely restricts spoofing to other services on the host. The generic ServeHttpRequest also carries no peer/socket provenance that could distinguish the direct request from the proxied one.
Consequently, while the Serve config is healthy, these two requests are identical to the constructor:
- an actual Serve-proxied request carrying the injected login header;
- a direct loopback request carrying a caller-authored login header.
Both receive AccessPathTailnetProxy. With otherwise valid Google session, CSRF and enrollment evidence, the second request passes the conjunct whose declared meaning is tailnet_path_required: true. This is not an anonymous remote bypass—the other authorization axes remain—but the transport/path proof is forged, so the model reports a fact it did not observe.
The seam and wet controls do not discriminate this defect. Their positive case supplies a synthetic header plus a synthetic matching status document; no value says whether the tested request came through Serve. The wet claim therefore proves the admitted write reaches durable storage, but not that the admitted request used the tailnet ingress.
Required repair: retain Serve status as configuration evidence, but require a per-request or structurally exclusive ingress proof before constructing AccessPathTailnetProxy. A protected Unix-socket backend whose custody makes tailscaled/Serve the sole caller, with modeled installation/readback, is one suitable boundary; another genuinely channel-bound proxy proof would also work. A normal loopback TCP listener plus status is insufficient. Add a discriminating red in which a direct local request has a fresh matching status and a forged nonempty Tailscale header but cannot mint the tailnet path, alongside the real proxied positive control. If the current socket seam cannot express that distinction, this route must remain AccessPathTailnetUnobserved until it can.
The stated unverified service-user execution of tailscale serve status --json is separate. Its current failure direction is appropriately fail-closed as a typed 503, but it still needs wet verification before enabling the live allocation writes.
No host or deployment mutation was performed in this review.
…e, not a status and a header Review 5406776483 (P1): serve status is a node-level configuration fact, so status + loopback bind + any non-empty Tailscale-User-Login header minted AccessPathTailnetProxy for a direct local request with a forged header -- the backend is a TCP listener any co-located process can dial. - RequestIngress (IngressUnattested | IngressAttestedPeer) carries what the serving host attests about the connection. request_access_path_of_standing judges it first: only a connection whose kernel-attested peer is the tailscale proxy (extdeps.tailscale.serve tailscale_serve_unix_proxy_peer, the fact the fabric door already relied on) can mint the tailnet path. - tailnet_route_standing refuses a declared route whose backend is a TCP port, whatever the reading says. - Every roadmap entry is launched on a TCP listener and hands the seam IngressUnattested, so no deployed instance mints the tailnet path and the allocation writes answer 503 ingress-unattested there. - New causes: ingress-unattested, ingress-not-the-proxy, route-backend-not-a-socket. Controls: same fresh status and forged header on an unattested listener and from an ordinary local account mint no tailnet path, while the proxy's connection does; the production instance as declared mints none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Re review 5406776483 (P1, serve status is config evidence, not ingress evidence): repaired at f9254e6.
Controls: Consequence, stated plainly: every roadmap entry is on a TCP listener today, so no deployed instance admits a write after this PR; they answer 503 |
briansrls
left a comment
There was a problem hiding this comment.
APPROVE at exact head f9254e62b450ecbd377f7a73696f76be2b1508b3.
Review 5406776483's P1 is addressed. Serve-status is now only node/configuration evidence; AccessPathTailnetProxy additionally requires a per-connection RequestIngress whose kernel-attested Unix-socket peer is the declared Tailscale proxy account. The existing gunbc serve --unix-socket seam derives that value with SO_PEERCRED, refuses a handler that declares peer_user behind TCP, and never takes it from request headers.
The route standing also refuses a declared TCP backend. Every currently rostered roadmap entry supplies IngressUnattested, skips the status subprocess, and answers the mounted writes with the typed 503 ingress-unattested; thus this PR does not silently enable writes before the separate protected-socket/Serve-retarget cutover.
The new controls are discriminating: with header and status fixed, unattested ingress and an ordinary-account Unix peer cannot mint the path, while the proxy peer can; the real current production row remains refused both because its listener is TCP and because its declared Serve backend is TCP. The exact backend comparison, freshness, typed cause surface, server-owned allocation terms, and durable create/release wet control remain intact.
The stated live gaps remain real acceptance gates: no current deployment admits a write, no real proxied request was exercised, and the srv1 serving principal's unelevated tailscale serve status --json read is unverified. Those are not source blockers for this fail-closed seam, but the socket-cutover PR must prove them before live enablement.
Exact-head required CI was still running when this review was submitted, so LAND is conditional on the ordinary exact-head/merge-group checks completing green. No host mutation was performed.
LAND once green.
…ssion read the shared allocation paths - workspace_write_unavailable_wire: keep main's single declaration (its producer has landed). - POST create/release rows and GET rows build from gunbc.roadmap_allocation_location (new allocation_release_route); workspace_allocation_admission's route_path reads allocation_page_path instead of the literal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What this does
gunbc.roadmap_serveroadmap_serve_request_securityhard-codedServeStatusUnreadable/ no route, soAccessPathTailnetProxycould not be constructed there and the allocation writes were unmounted. This PR builds the seam that can construct it and mounts both writes undertailnet_path_required: true. It owns the single mount of both POST routes (#13272 dropped its mounts).On no instance deployed today does a write get admitted. Every roadmap entry is launched on a TCP listener, which attests no peer, so the writes answer
503 unavailable / ingress-unattestedthere. Admission needs the deployment cutover described under "Live step" below, which this PR does not model or perform.The tailnet path needs three things, and the header is none of them
gunbc.auth.request_authenticationrequest_access_path_of_standingmintsAccessPathTailnetProxyonly when:RequestIngressisIngressAttestedPeer { peer_user }withpeer_userequal toextdeps.tailscale.servetailscale_serve_unix_proxy_peer. This is the per-connection fact review 5406776483 asked for. It is whatgunbc serve --unix-socketalready hands a handler aspeer_user(SO_PEERCRED), and what the fabric door already relies on; that row now reads the same extdeps fact. A TCP listener isIngressUnattested;gunbc.auth.tailnet_serve_readbacktailnet_route_standingover a datedtailscale serve status --jsonreading, at the listener × mount key (tailscale_serve_status_route_proxy_targets), with the backend equal to the declared one, and the declared backend a unix socket. A route declared to a TCP port is never observed, whatever the reading says.Ingress is judged first. Anything else with a header is
AccessPathTailnetUnobserved { gap }, which grants whatAccessPathLocalDevgrants and names its cause.request_access_path_ofkeeps its role for the broker and takes the sameingress.Routes
gunbc.workspace_allocation_routesworkspace_route_create/workspace_route_release. Expiry, image digest and public key reference come fromworkspace_create_terms. A signed-in request on the unobserved path is answered503 {"availability": "unavailable", "reason", "cause"}, derived from that request's access path. An unauthenticated request gets 401 and no host detail; no header or a direct bind stays 403.Cause labels:
ingress-unattested,ingress-not-the-proxy,route-backend-not-a-socket,route-undeclared,readback-not-demanded,readback-clock-unreadable,readback-not-spawned,readback-command-failed,readback-stale,status-unreadable,route-not-proxied,route-proxies-elsewhere.Evidence
Floor claims (reading, ingress and declared route supplied to the seam's fold):
seam_forged_header_on_direct_local_ingress_mints_no_tailnet_patha_forged_header_on_direct_local_ingress_mints_no_tailnet_pathtest.claim.request_authentication_witness_testseam_production_instance_declares_a_tcp_route_and_mints_no_tailnet_pathroadmap_serve_access_pathon the real roster instance: unattested as launched; refused even with proxy ingress because its route is a TCP portseam_fresh_matching_route_mints_the_tailnet_path_and_passes_admissionAccessPathTailnetProxy; create reaches the storeseam_missing_route…,seam_mismatched_route…,seam_header_without_a_readable_reading…,seam_stale_readback…seam_each_unvouched_cause_is_its_own_arm,seam_permission_denied_refusal_names_the_command_failureseam_malformed_identity_refuses_the_writeseam_csrf_and_release_are_held_to_the_same_law,seam_browser_supplied_terms_are_refused,seam_both_allocation_writes_are_mountedWet-lane claim
seam_admitted_create_and_release_land_in_the_store: the admitted create and release land in a file store with the server's terms; the unvouched variant leaves the ledger empty.Local
gunbc runover a scratch driver: all green on this head. Mutation on this head: accepting any attested peer as the proxy turnsseam_forged_header_on_direct_local_ingress…red. Earlier heads: weakening the policy row, dropping the backend comparison, loosening freshness, and folding command-failure into "not proxied" each turned a claim red.Not established here
IngressAttestedPeer { root }, a socket-backed declared route and a status text. No rostered instance declares a socket backend, so the positive path does not run on a real roster row.peer_user, and the Serve route must targetunix:<socket>. That changes the deployment declarations and the installed Serve mapping, so it is its own change with its own go-ahead. Until then production answersingress-unattested.tailscale serve status --jsonas the serving principal is unverified on srv1. Its failure direction is a typed 503readback-command-failed.workspace_demo_image_identityduplicates alloc unit 3: initial commissioning #13191's text andworkspace_write_unavailable_wiremay duplicate Allocation page: live client over the real workspace contract #13272's; the later lander keeps one of each and builds the route rows fromgunbc.roadmap_allocation_location.🤖 Generated with Claude Code