Repository navigation
srv1 dashboard tailnet door: socket-bound serve unit, serve route retargeted to it, door entry declaring peer_user (modeled; live retarget not performed) - #13296
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>
…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>
…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>
…e route retargeted to it, door entry declaring peer_user
After the request-security seam requires a kernel-attested proxy peer, no
deployed instance can mint the tailnet path: every roadmap entry is on a TCP
listener. This models the cutover for production (modeled only; nothing is
applied to a host):
- gunbc.roadmap_dashboard_instance dashboard_instance_tailnet_door_socket: the
socket in the instance root.
- gunbc.live_deploy.spec: srv1_live_deployment_names routes the root mount to
unix:<door socket>; a deployment whose route targets a socket owns one
TailnetDoorServeUnit member, ordered before its serve mapping. The socket is
read back out of the route's backend, so unit and mapping cannot disagree.
- gunbc.live_deploy.emit: the door unit is the loopback serve unit on another
entry and listener (live_deploy_serve_unit_file_over); apply and teardown
arms. The loopback unit and its bytes are unchanged and keep serving the
host's own readers.
- gunbc.roadmap_serve roadmap_serve_handle_door: the entry the unit launches,
handing the seam IngressAttestedPeer { peer_user }.
Witnesses: one socket across route, unit and mapping; door unit equals the
loopback unit up to entry and listener; door precedes the mapping and the
port-routed twin has none; the door entry answers; and the seam over the real
roster row mints the tailnet path only through the door.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eld to the release identity A deployment whose serve route targets a unix socket has two serving processes and every tailnet request goes to the door, which deploy readiness did not probe. live_deploy_service_ready_once_for_deploy now joins the loopback observation with the same /healthz read over the door socket (extdeps.http.client GetWithinUnixSocket / http_client_get_unix_socket_argv), held to the same release and surface identity. A door that is unanswered or serving another release is ServiceTailnetDoorPending naming the socket, which the poll bound turns into the deploy's typed refusal. A door observation never upgrades a loopback one, and a deployment with no door is its loopback observation unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # dag/extdeps/tailscale/serve.dag # dag/gunbc/roadmap/roadmap_serve.dag # dag/test/claim/roadmap/workspace_allocation_http_witness_test.dag
…ain merge The merge of main reported a third conflicted file that I did not see and staged with its markers, so the corpus did not parse. Both sides were this branch's own text; the content is unchanged from before the merge. 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 7f3248271ac346cf18f59f2c80920c25dd92318f.
The socket/peer authority itself is sound: production derives one door socket into the unit and Serve mapping, TCP requests remain unattested, the door entry consumes kernel-derived peer_user, and readiness joins the loopback and door health documents to the same release identity. Exact-head required CI is green.
P1: the apply transaction redirects the live root route before it proves the door ready, and a failed readiness gate has no compensation. TailnetDoorServeUnit runs systemctl restart on a Type=simple service, then the next deployment member immediately executes tailscale serve --bg --https=443 unix:<socket>. Only after the entire mutation returns does live_deploy_apply_readiness_gate probe the loopback listener and door. A successful systemctl restart proves only that ExecStart was accepted, not that the socket exists, is traversable, or serves the candidate release.
The existing transaction explicitly justifies route-before-readiness only because the old operation reasserted the same endpoint-to-8080 mapping; this PR changes that premise into a real backend redirection. Failure sequence:
- door unit start returns successfully but the process exits, cannot bind/traverse the socket, or serves a stale/wrong release;
- apply re-points all browser traffic from 127.0.0.1:8080 to the socket;
- the later door readiness probe refuses;
- the deployment returns NotConverged, but the route remains pointed at the failed door. Retract turns the route off; it does not restore the old port mapping.
So the source can report a failed deploy while leaving the production dashboard unavailable. The roster-order witness proves only restart precedes serve; it does not discriminate readiness-before-cutover.
Required repair: make door readiness a cutover permit, not a post-mutation diagnosis. Install/start the door, obtain a bounded direct-socket /healthz readback for the exact release/surface, then allow the mapping re-point. After re-point, read back the exact Serve target and exercise the proxied path; if that second phase fails, restore the captured prior port mapping (or otherwise retain it until the new route is committed). The old backend must be carried as rollback authority rather than reconstructed.
Add effect-level controls showing: a missing/wrong-release door emits no re-point; exact door readiness permits the re-point; a post-repoint status/proxy failure restores the old target; and retry is idempotent. The existing live checks (service-principal status read, socket traversal, actual proxied request, concurrent two-process memory within the existing slice) remain necessary after the source repair.
No host mutation was performed by this review.
…e-point is read back and exercised, a failure restores the captured backend Review 5407953744 (P1): the door unit's restart was immediately followed by the serve root route re-point inside the bulk apply, with readiness only afterwards and no compensation, so a dead or stale door left production pointed at it. - gunbc.live_deploy.tailnet_door_cutover: the transaction. Door readiness mints TailnetDoorReadyForCutover (sole constructor, one mint); with it the run observes the route and captures its backend, re-points, reads the route back, exercises the proxied path and only then commits. After a failure it restores the CAPTURED backend and proves it by readback; it restores only while the route is still the door it wrote, never over a newer mapping. A route already at the door is a retry: nothing is re-pointed. - The mapping member emits no apply step for a door-routed deployment (emit tailscale_serve_mapping_apply_steps); port-routed deployments are unchanged. - HostEffect LiveDeployServeRoute: one serve route written or turned off through the ordinary mutation gate, used for the re-point and the restore. - gunbc.live_deploy.apply: mutation -> readiness poll (loopback and door) -> cutover gate -> digest and identity readbacks. Anything but a committed cutover is NotConverged carrying what became of the route. - extdeps.tailscale.serve tailscale_serve_backend_of_proxy_target restates a captured status target as a serve backend, or nothing. Controls run the transaction over supplied effects and read its step record: unready or wrong-release door performs no effect; exact readiness re-points exactly once; proxied failure and unreadable post-status restore the captured backend; a newer mapping is never overwritten; retry is idempotent; the deploy's own gate refuses an unready door before any host effect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Re review 5407953744 (P1, route re-pointed before the door is proven, no compensation): repaired at 096acdf. The mapping member no longer writes the route in the bulk apply for a door-routed deployment. Effect-level controls are in Three places where I did less than a literal reading, each stated in the body: a post-re-point mismatch restores only when the route is unreadable or still the door (a third backend is treated as a newer mapping and left alone); two concurrent cutovers writing the same door target are not told apart by this code and rely on the apply lease; and |
briansrls
left a comment
There was a problem hiding this comment.
HOLD at exact head 096acdf69527dea76336adc4e4e5870158eb8710.
The prior ordering finding is substantially repaired: a socket-routed mapping emits no bulk-apply route write; direct socket readiness precedes the cutover; the route is observed, re-pointed once through LiveDeployServeRoute, read back, exercised through the tailnet name, and compensated from the captured backend on an in-process failure. Leaving an explicitly observed third backend alone is the right rule.
Two P1s remain:
- The captured backend is process-local, so interruption after re-point loses the rollback authority.
CapturedServeBackendis only a local value intailnet_door_cutover_run; there is no durable Prepared/Repointed/Committed journal. If the workflow/process is cancelled, times out, or crashes after the route write (or after a failed proxied probe but before restore), the GitHub host-mutation concurrency group releases. A replacement run observes the route already at the door and enterstailnet_door_cutover_already_routed. The committed control explicitly establishes that when the proxied path then fails, it refuses and performs no restore, because the prior backend is gone. Thus the former failure mode remains reachable across an interruption: failed deployment, production route still pointed at the failed door.
The host lease serializes live jobs; it does not survive their termination or preserve prestate. Add a generation-fenced durable cutover journal binding route key, reviewed plan/release, door target, captured prior backend, and phase. Persist Prepared before the route effect, record Repointed, then Committed or RolledBack. Recovery from Prepared/Repointed must use the durable capture; a committed retry must not roll back. Add controls for interruption after re-point, after failed proxy observation before restore, and after restore before proof/publication, plus stale/foreign journal refusal.
- A single prior target is overwritten before the code proves it can restore it. The first observation admits any one
StringasCapturedBackend.tailscale_serve_backend_of_proxy_targetis consulted only later insidelive_deploy_serve_route_restore, after the route has already been changed. A valid or drifted Serve target outside the inverse's unix/loopback-port subset can therefore be displaced, followed by a rollback that can only report that the captured backend cannot be restated while leaving the door in place. Make capture produce a typed restorable backend before mutation (or refuse the cutover before re-point when the observed target is not exactly representable). Add a RED for one unrepresentable prior target causing zero route effects.
The disclosed no-CAS limitation is acceptable only inside that durable, lease-bound recovery model. The current in-memory transaction is not crash-recoverable, so this head should not land or be used for the live retarget yet.
…able capture, resume from the journaled capture Review 5408258646, two P1s. (1) The captured prior backend was a local value, so a run interrupted after the re-point lost the rollback authority and its replacement, finding the route at the door, could only refuse. The transaction is now journaled in one compare-and-set slot of the fabric state store per (host, route), binding route, door target, release, the capture and the phase: Absent -> Prepared -> Repointed -> Committed | RollingBack -> RolledBack Prepared is written BEFORE the route effect. A run that reads an in-flight record resumes it with the journal's capture: route still the capture -> re-point (with the permit) or settle RolledBack (without); route at the door -> exercise, then commit or restore the durable capture; RollingBack -> finish the rollback. A Committed record is never rolled back. An unreadable journal, a record about another route or door, an in-flight record left by another release, and a write fenced by a newer generation each refuse with no route effect. A blind restore over an unreadable route is made only by the run whose RollingBack write lands. The gate now runs whatever the readiness was, so an interrupted cutover is resumed even when this run's door is not ready; a mutation that did not converge yields no permit. (2) Capture accepted any single target and checked restorability only inside the restore. serve_route_capture now yields CapturedNoRoute or CapturedRestorableBackend carrying its backend word, or refuses before the route is touched; an unrestorable prior target causes zero route effects. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Re review 5408258646: both P1s repaired at ce6cf2c. (1) Durable journal. One generation-fenced compare-and-set slot in the fabric state store per (host, route) binds route, door target, release, capture and phase. Prepared is written before the route effect; Repointed, Committed, RollingBack and RolledBack follow. A run that finds an in-flight record resumes with the journal's capture, so an interruption after the re-point, after a failed probe, or after the restore but before its proof each ends in a commit or a proven restoration of the durable capture. A Committed record is never rolled back. Unreadable journal, another route or door, an in-flight record from another release, and a write fenced by a newer generation refuse with no route effect. The cutover gate now runs whatever the readiness was, since the case that most needs a resume is the one where the door is dead. (2) Typed capture. Two things to weigh, both in the body under Limits: an in-flight record is resumable only by the release that wrote it, so a release change after an interruption strands the route until the original plan is re-run; and the interruption controls start a run from the journal an interrupted run would have left — no claim kills a process mid-transaction. |
briansrls
left a comment
There was a problem hiding this comment.
APPROVE at exact head ce6cf2c0c302a1c81018432dc366b824d7f817e4.
The two blockers from review 5408258646 are addressed.
- Cutover state is now durably journaled in one generation-fenced fabric-state CAS slot per host/route. The captured prior backend is persisted in Prepared before any route effect; Repointed/RollingBack/Committed/RolledBack allow an interrupted run to resume from durable state rather than guessing. Foreign in-flight releases, stale generations, unreadable journal state, and newer mappings fail closed. The real-store wet control establishes survival across handles and stale-write fencing.
- Capture is typed as CapturedNoRoute or CapturedRestorableBackend. An unsupported singleton target refuses before journaling/repoint, so rollback feasibility is established before mutation.
The former ordering P1 remains closed: door readiness precedes cutover, the door-routed mapping emits no bulk-apply route write, cutover reads back the exact door, exercises the proxied path, and commits only after its journal write. Failed probes enter journaled rollback and restore the durable capture; restoration is proven by readback. Committed state is never rolled back.
I accept the stated concurrency limitation: Tailscale exposes no route CAS, so live cutovers rely on the existing per-host mutation lease; the transaction still refuses to overwrite an observed third/newer mapping. This should remain explicit in live acceptance.
Live acceptance still must establish the deploy job's fabric-store write authority, socket traversal, unelevated Serve-status read, exact Unix-target status spelling, proxied ingress, concurrent memory fit, and interruption recovery against the real host.
LAND, conditional on exact-head/merge-group CI completing green.
Modeled declarations only. Nothing here was applied to a host, and merging does not apply it —
fleet-convergeis dispatch-only.Why
After #13271 the tailnet path needs a kernel-attested proxy peer and a socket-backed route. Every roadmap entry is on a TCP listener, so no deployed instance can admit an allocation write. This is the production cutover that makes one possible.
What changes
Door socket and unit.
gunbc.roadmap_dashboard_instancedashboard_instance_tailnet_door_socket;gunbc.live_deploy.specroutes production's root mount on 443 tounix:<door socket>and gives a socket-routed deployment oneTailnetDoorServeUnitmember. The socket is read back out of the route's backend, so unit and mapping cannot name two sockets.gunbc.live_deploy.emitlive_deploy_tailnet_door_unit_fileis the loopback serve unit on another entry and listener. The loopback unit's bytes are unchanged and it keeps serving the host's own readers.Door entry.
gunbc.roadmap_serveroadmap_serve_handle_doordeclarespeer_userand hands the seamIngressAttestedPeer.Door in readiness.
gunbc.live_deploy.readinessjoins the loopback observation with/healthzover the door socket, held to the same release and surface identity.The re-point is a journaled transaction (reviews 5407953744, 5408258646).
gunbc.live_deploy.tailnet_door_cutover, with the journal in one compare-and-set slot of the fabric state store per (host, route) (tailnet_door_cutover_host), binding route, door target, release, capture and phase:TailnetDoorReadyForCutover(sole constructor, one mint)CapturedNoRouteorCapturedRestorableBackendcarrying its backend word; unreadable, ambiguous or unrestorable ⇒ refuse before the route is touchedLiveDeployServeRoutehost effect through the ordinary mutation gate; journal Repointed/healthzat the tailnet name, held to the release identityResuming. A run that reads Prepared, Repointed or RollingBack uses the journal's capture: route still the capture → re-point with the permit, or settle RolledBack without it; route at the door → exercise, then commit or restore; RollingBack → finish the rollback. A Committed record is never rolled back. The gate runs whatever the readiness was, so an interrupted cutover is resumed even when this run's door is not ready; a mutation that did not converge yields no permit.
The restore is applied only while the route still shows the door, or — when the route cannot be read — only by the run whose RollingBack write lands at the generation it holds. A route showing anything else is left alone. The mapping member emits no apply step for a door-routed deployment; port-routed deployments are unchanged.
gunbc.live_deploy.applyruns mutation → readiness poll → cutover gate → readiness gate → digest and identity readbacks.Evidence
test.claim.live_deploy.tailnet_door_cutover_witness_testruns the transaction itself over supplied effects and reads its step record:an_unanswering_door_performs_no_effecta_door_serving_another_release_performs_no_effecta_ready_door_is_journaled_repointed_once_read_back_exercised_and_committedan_unrestorable_or_unjournaled_capture_causes_zero_route_effects(also unreadable, ambiguous, fenced and refused journal writes)an_interruption_after_the_repoint_resumes_from_the_journaled_capture(proxied fails → restore durable capture; answers → commit; door dead → restore)an_interruption_at_prepared_resumes_with_the_same_capturean_interruption_during_the_rollback_finishes_the_rollbacka_retry_over_a_committed_cutover_never_rolls_backan_unreadable_or_foreign_journal_refuses_before_any_effect,another_releases_in_flight_cutover_refuses_and_its_settled_one_does_notan_unrestorable_or_unjournaled_capture…(fenced Prepared),an_unrecorded_commit_is_not_converged_and_leaves_the_route_working, and on a real storethe_cutover_journal_survives_its_writer_and_fences_a_stale_write(wet lane)an_unreadable_route_is_restored_only_by_the_run_holding_the_journala_route_not_at_the_door_after_the_repoint_is_not_committed,a_failed_proxied_path_restores_the_captured_backend_and_proves_it,a_failed_cutover_from_no_route_restores_no_route,a_newer_mapping_is_never_overwritten_by_the_restore,an_unproven_restore_stays_in_flight_in_the_journalthe_deploy_gate_refuses_an_unreadable_journal_before_any_host_effect,a_failed_mutation_cannot_permit_a_repoint,the_door_route_is_written_by_the_cutover_and_not_by_the_bulk_apply,only_a_committed_cutover_leaves_the_deployment_converged,the_journal_record_round_trips_and_an_unrestorable_capture_does_not_decodeAn interruption is modeled as a run that starts from the journal record the interrupted run's last write left, with the route where its last route effect put it. No claim kills a process mid-transaction.
The captured backend in these controls is deliberately not production's port, so a passing restore can only have used the first observation.
Also
test.claim.live_deploy.tailnet_door_witness_test(one socket across route, unit and argv; door unit equals loopback unit up to entry and listener; door readiness join) andseam_production_instance_mints_the_tailnet_path_only_through_its_doorover the real roster row.Two existing claims in
emit_testchanged because the fact they pinned moved: the bulk apply now must not contain production's route write, and the root-mount/no---set-pathfact is read from the cutover's script.Local
gunbc run: the cutover, journal (wet), door, seam andemit_testfiles green, 131 claims. Mutations on this head each turned a claim red: resuming another release's in-flight record; accepting an unrestorable capture; restoring blind without the journal; re-pointing without journaling. Earlier heads: restoring over a newer mapping; dropping the already-routed arm; putting the route write back in the bulk apply; pointing production's route back at the port. Floor result is CI's.Limits of the transaction, stated
tailscale servehas no compare-and-set. The journal fences the record, not the route write itself; a run that loses the fence after its Prepared write can still have issued one re-point. Mutual exclusion of live jobs remains the apply's host lease.Live steps — declared, not performed, operator go-ahead needed
Applying on srv1 installs and starts
gunbc-roadmap-tailnet-door.service, then runs the transaction above against the real route. Still to be established on the host: that the deploy job can write the cutover journal slot in the fabric state store; the service principal's unelevatedtailscale serve status --jsonread; that the deploy principal can traverse the instance root to the socket; the exactProxytext tailscaled reports for a unix target; one real proxied request; two serving processes within the existing slice's memory.🤖 Generated with Claude Code