Skip to content

Allocation write seam: tailnet path from an attested proxy ingress plus serve-status readback; mount POST /allocations and /allocations/{id}/release - #13271

Merged
gunbai-bot[bot] merged 4 commits into
mainfrom
session/wise-wolf-139
Oct 4, 2026
Merged

gunbai-bot[bot] merged 4 commits into
mainfrom
session/wise-wolf-139

Conversation

@gunbai-bot

@gunbai-bot gunbai-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

What this does

gunbc.roadmap_serve roadmap_serve_request_security hard-coded ServeStatusUnreadable / no route, so AccessPathTailnetProxy could not be constructed there and the allocation writes were unmounted. This PR builds the seam that can construct it and mounts both writes under tailnet_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-unattested there. 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_authentication request_access_path_of_standing mints AccessPathTailnetProxy only when:

  1. the bind is loopback (unchanged);
  2. the connection's attested peer is the tailscale proxy — RequestIngress is IngressAttestedPeer { peer_user } with peer_user equal to extdeps.tailscale.serve tailscale_serve_unix_proxy_peer. This is the per-connection fact review 5406776483 asked for. It is what gunbc serve --unix-socket already hands a handler as peer_user (SO_PEERCRED), and what the fabric door already relies on; that row now reads the same extdeps fact. A TCP listener is IngressUnattested;
  3. the node's serve config shows the declared route proxying this backend — gunbc.auth.tailnet_serve_readback tailnet_route_standing over a dated tailscale serve status --json reading, 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 what AccessPathLocalDev grants and names its cause. request_access_path_of keeps its role for the broker and takes the same ingress.

Routes

gunbc.workspace_allocation_routes workspace_route_create / workspace_route_release. Expiry, image digest and public key reference come from workspace_create_terms. A signed-in request on the unobserved path is answered 503 {"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):

Claim Asserts
seam_forged_header_on_direct_local_ingress_mints_no_tailnet_path same fresh matching status + same header: unattested listener and an ordinary local account mint nothing and the write is 503; the proxy's connection mints the path
a_forged_header_on_direct_local_ingress_mints_no_tailnet_path the same control at the fold in test.claim.request_authentication_witness_test
seam_production_instance_declares_a_tcp_route_and_mints_no_tailnet_path through roadmap_serve_access_path on the real roster instance: unattested as launched; refused even with proxy ingress because its route is a TCP port
seam_fresh_matching_route_mints_the_tailnet_path_and_passes_admission positive control: proxy ingress + socket-backed route + fresh reading → AccessPathTailnetProxy; create reaches the store
seam_missing_route…, seam_mismatched_route…, seam_header_without_a_readable_reading…, seam_stale_readback… each 503 with its own cause
seam_each_unvouched_cause_is_its_own_arm, seam_permission_denied_refusal_names_the_command_failure causes do not collapse; the body carries exit status and client text
seam_malformed_identity_refuses_the_write no login → 403; malformed session → 401 with no host detail
seam_csrf_and_release_are_held_to_the_same_law, seam_browser_supplied_terms_are_refused, seam_both_allocation_writes_are_mounted as named

Wet-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 run over a scratch driver: all green on this head. Mutation on this head: accepting any attested peer as the proxy turns seam_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

  • No claim runs a real proxied request. The positive control supplies 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.
  • Live step, not modeled and not performed. For writes to be admitted on srv1: the roadmap unit must serve on a protected unix socket through an entry that declares peer_user, and the Serve route must target unix:<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 answers ingress-unattested.
  • tailscale serve status --json as the serving principal is unverified on srv1. Its failure direction is a typed 503 readback-command-failed.
  • A root process on the host is indistinguishable from the proxy by peer; root is already trusted with the store.
  • workspace_demo_image_identity duplicates alloc unit 3: initial commissioning #13191's text and workspace_write_unavailable_wire may duplicate Allocation page: live client over the real workspace contract #13272's; the later lander keeps one of each and builds the route rows from gunbc.roadmap_allocation_location.

🤖 Generated with Claude Code

…; 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>
gunbai-bot Bot pushed a commit that referenced this pull request Oct 4, 2026
…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>
gunbc-ci-auto-heal and others added 2 commits October 4, 2026 13:06
…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>
gunbai-bot Bot pushed a commit that referenced this pull request Oct 4, 2026
…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 briansrls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. an actual Serve-proxied request carrying the injected login header;
  2. 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>
@gunbai-bot gunbai-bot Bot changed the title Allocation writes: serve-status readback at the request-security seam; mount POST /allocations and /allocations/{id}/release Allocation write seam: tailnet path from an attested proxy ingress plus serve-status readback; mount POST /allocations and /allocations/{id}/release Oct 4, 2026
@gunbai-bot

gunbai-bot Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Re review 5406776483 (P1, serve status is config evidence, not ingress evidence): repaired at f9254e6.

AccessPathTailnetProxy now requires a per-connection fact in addition to the status: RequestIngress must be IngressAttestedPeer whose kernel-attested peer is the tailscale proxy (extdeps.tailscale.serve tailscale_serve_unix_proxy_peer, the SO_PEERCRED value gunbc serve --unix-socket already hands a handler). Ingress is judged before the status. A declared route whose backend is a TCP port is never observed.

Controls: seam_forged_header_on_direct_local_ingress_mints_no_tailnet_path and the fold-level twin hold status, route and header fixed and vary only the ingress — unattested and ordinary-account ingress mint nothing, proxy ingress mints the path. seam_production_instance_declares_a_tcp_route_and_mints_no_tailnet_path runs the real roster instance through the seam.

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 ingress-unattested. The socket entry plus the Serve target change on srv1 is a separate change and is neither modeled nor performed here. The positive control therefore supplies the ingress and a socket-backed route; no claim runs a real proxied connection.

@briansrls briansrls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gunbai-bot
gunbai-bot Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 0890908 Oct 4, 2026
4 checks passed
@gunbai-bot
gunbai-bot Bot deleted the session/wise-wolf-139 branch October 4, 2026 18:55
gunbai-bot Bot pushed a commit that referenced this pull request Oct 4, 2026
…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>
@briansrls
briansrls restored the session/wise-wolf-139 branch October 5, 2026 00:39
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