Skip to content

Commit 1f0019c

Browse files
lib: Mac static-IP IPIP dataplane with mandatory ESP and forward-only rekey (#18)
* lib: add /connect-ipip endpoint for IPIP tunnel peers Squash of codesmith/connect-ipip-endpoint (15 commits, tip 35d57ab), rebased onto main across the iptables-rules refactor (#15/#17). Serves the macOS static-IP path: Mac hosts POST /connect-ipip, vprox allocates an inner IP from WgCidr, creates a kernel ipip tunnel keyed by the client's source address, and installs a per-peer FORWARD accept/drop filter plus wildcard MSS clamping. The agent-side half (pf route-to + gif, fa#3824/#3841) has been deployed since May; this server half was never merged, so every /connect-ipip returned 405 fleet-wide. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: mark readiness fatal if the stale IPIP sweep fails If LinkList fails, HTTPS never listens but heartbeats previously stayed on starting/stale. Match WireGuard and iptables setup failures so the box reports unhealthy. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: guard IPIP map and kernel tunnels with one mutex Drop ipipCreateMu. ipipMu now covers peer state and LinkAdd/LinkDel so idle teardown cannot delete the map entry while the (local, remote) pair still exists. HTTP writes and idle stats probes stay off the lock. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: do not hold ipipMu across the startup sweep Sweep runs before HTTPS listen and never touches the peer map, so a lock around LinkList plus every leftover LinkDel only delays bringing the box up. Log create/teardown that block on netlink/xtables for 5s so a pile of static-IP setup failures is diagnosable. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: drop the IPIP kernel-op hang timer Observability is VM static-IP setup duration/failures. A 5s side goroutine did not fail faster and was extra machinery on create/teardown. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: adopt leftover IPIP tunnels across restarts Replace the startup sweep with RestoreIpipFromKernel: valid vp* ifaces are adopted into Go state (Mac clients cache gif with no keepalive, so deleting them blacks peers out), and only invalid leftovers are deleted. Teardown becomes honest to make that safe: tearDownIpipLink returns an error, inner IPs are Freed only after LinkNotFound confirms the iface is gone, and the idle reaper is now a vanished-only reaper so a living tunnel is never deleted for quietness. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: add opt-in ESP to the IPIP outer path A client may send {"esp": true} to /connect-ipip; the server mints one AES-GCM (rfc4106) SA per direction with crypto/rand, installs transport-mode xfrm state plus require-ESP policies scoped to proto-4 between the two hosts (so plaintext IPIP from a spoofer is dropped), sets the tunnel MTU to fit the ESP overhead, and returns SPIs and keys in the TLS-protected response. Old clients send {} and get the exact pre-ESP behavior, including removal of any leftover require-ESP policy on rollback. Re-connecting re-mints and replaces the pair's SAs; there is no rekey machinery. Restart adoption leaves kernel xfrm untouched so ESP keeps flowing while the process is down. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: use aes-cbc+hmac-sha256 for IPIP ESP Staging E2E on the real boxes showed macOS setkey's PF_KEY grammar has no AEAD tokens at all ("syntax error at [aes-gcm]"), so rfc4106 AES-GCM cannot be installed on the Mac side. Switch to AES-128-CBC with HMAC-SHA-256 truncated to 128 bits: macOS xnu implements RFC 4868 truncation, not the legacy 96-bit KAME truncation that Linux defaults to for hmac(sha256), so the TruncateLen must be explicit (the mismatch shows up as XfrmInStateProtoError with zero decrypted packets). CBC+HMAC worst-case overhead is larger than GCM's, so the ESP tunnel MTU drops to 1424. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: add rekey mode to /connect-ipip for ESP SA rotation A client can now POST {"esp": true, "rekey": true} to rotate SA generations without tearing down the tunnel. vprox mints a new SPI/key pair, installs the new inbound SA alongside the old ones, and returns the material; its outbound switches to the new generation once the new inbound SA's packet counter first ticks (500ms poll, 60s timeout, on timeout switch anyway and log). Two minutes after a counter-confirmed switch, older generations are GC'd, keeping the newest previous inbound so a client that rolled back after a failed health check keeps working; timeout-confirmed switches skip GC entirely and leave the sweep to the next confirmed rekey, which also collects generations orphaned by a vprox restart. Fresh connects keep the existing destructive replace. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: keep adopted peers' ESP when deleting duplicate leftovers RestoreIpipFromKernel removed ESP state for every deleted leftover, but ESP objects are keyed by the address pair: a duplicate-remote leftover shares its pair's live SAs/policies with the adopted tunnel, so deleting the duplicate silently stripped the adopted tunnel's encryption. Skip pair-level ESP removal for remotes owned by adopted peers. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: delete the IPIP link before removing its peer filters tearDownIpipLink removed the per-peer FORWARD filters before LinkDel, so a failed delete left a live tunnel forwarding without spoof protection. Delete the link first (LinkNotFound counts as gone) and remove filters only once the link is confirmed absent; the error contract is unchanged (an error still means the link may remain, so the caller must not Free). Co-authored-by: Cursor <cursoragent@cursor.com> * lib: unwind ESP install when it fails after the xfrm objects land installIpipEsp could return an error from the iface lookup / MTU step after the SAs and require-ESP policies were already installed. The handler then returns 500, the client never receives the minted keys, and the pair is left require-ESP'd in the kernel with keys nobody holds -- a blackhole until a successful retry. Unwind the freshly installed xfrm objects (and log the unwind) before returning. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add ESP rollback runbook and accepted threat model Co-authored-by: Cursor <cursoragent@cursor.com> * lib: make ESP rekey forward-only with prepare/activate/abandon steps Rollback-as-deletion-and-recreation is unsound: re-adding an outbound SA resets its sequence counter while the peer's inbound anti-replay high-water mark survives, so on a mature tunnel every rolled-back packet is dropped as a replay. Generations now only move forward, and each step is driven explicitly by the client: - PREPARE ({"esp":true,"rekey":true}) installs BOTH new-generation states; the outbound rides a per-generation reqid (derived from SpiToClient) that the outbound policy does not select yet, so the wire keeps flowing on the active generation. Late failures unwind the freshly added states (orphan-SPI fix). - ACTIVATE flips the outbound policy template to the new reqid. The old state is retained, so the flip is reversible and replay-safe (verified on staging: xfrm resumes the retained state's oseq). Idempotent by SpiToClient. - ABANDON walks a failed rotation back: a pending generation's states are deleted; an activated one has the policy flipped back to the pre-activation reqid, states left for the next rotation's sweep. Inbound states and the in-policy keep reqid 0 so every installed inbound generation stays acceptable at once (xfrm requires exact reqid matches between template and state, verified on staging including reqid 0 selecting only legacy states). GC is gated on dataplane evidence: after ACTIVATE a poll watches the new inbound SA's packet counter and only sweeps old generations (plus strays) once it ticks; on timeout nothing is deleted. The old timeout-activate ("switch anyway") is gone. A pending generation the client never claims is reaped after a timeout, or replaced by the next PREPARE, without touching active states. A rekey against a pair with no live SAs falls back to the destructive full install and reports Fresh=true so the client treats the tunnel as lost, not rotated. Also from the review: peer teardown now supersedes in-flight GC/reap goroutines via a process-global monotonic epoch (a stale goroutine can never act on a re-created peer for the same client IP), and createIpipLink deletes stale per-peer FORWARD rules for the interface name before appending fresh ones, so a leftover DROP can never sit ahead of the new ACCEPT after iface-name reuse. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: update ESP runbook for forward-only rekey and deploy backups The rollback contract changed: a rolled-back (pre-ESP) server does NOT keep traffic flowing on its own -- live pairs hold require-ESP policies on both sides, so the kernel flushes are mandatory, not an optimization. Point the binary-downgrade step at the vprox.bak-<date> copies the deploy playbook now retains next to the checkout, and restate the forward-only invariant (no SA is ever deleted and re-added; retirement is gated on kernel counters). Co-authored-by: Cursor <cursoragent@cursor.com> * lib: harden forward-only rekey and make ESP mandatory on /connect-ipip Rekey hardening: an ACTIVATE is now provisional -- if no packet arrives on the new generation's inbound SA within the 2-minute activation gate, the server flips the outbound policy back to the previous generation's reqid on its own (idempotent with a late ABANDON via epoch and current-reqid guards). Rekey mutations are version-gated: every request with "rekey" must carry espRekeyV=2 or it is rejected with a 400 before any state is touched. Restore reconciliation verifies adopted IPIP links (up, /32 route, correct local endpoint) and demotes irreparable ones to deletion, freeing their allocated IPs. ESP mandatory: /connect-ipip rejects plaintext bodies ({}, empty, or esp:false) with a 400 "ESP required" before any peer is looked up or created; the {}-strips-ESP rollback path is deleted. /connect-ipip has never carried production traffic (prod vproxes 405 it), so there are no deployed plaintext clients to stay compatible with. The WG /connect path is unaffected. Docs: runbook updated -- ESP is mandatory, the emergency brake is stopping static-IP admission (not a plaintext downgrade), and the rollout gate is registering no us-central orgs until both vprox and agents are deployed from matching revisions. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: lock the forward-only ESP contract: fenced v1 wire + one housekeeping sweep /connect-ipip is now version 1 with an explicit op (connect | prepare | activate); missing or unknown version/op -- including the empty body and every legacy shape -- is a 400 before any peer lookup, so no request can imply a destructive install. ACTIVATE is a compare-and-swap fenced on kernel truth: the outbound policy flips only if it currently selects expectedActive's generation, a superseded/replayed activate gets a 409 naming the real active generation and mutates nothing, and re-activating the active target is idempotent. The rollback machinery is deleted entirely: the ABANDON op, the activation auto-revert, the prev-generation/revert-reqid bookkeeping, the espRekeyV version gate (the versioned op is the gate), and every per-rekey goroutine plus the espRekeyEpoch counter that guarded them. Failure handling is one housekeeping sweep extending the 5s vanished-peer reaper: pending generations reaped after a deadline (confirmed absent before bookkeeping clears), counter-gated GC of superseded generations after switch proof plus grace, orphan aging by kernel AddTime, one xfrm dump per pass under a snapshot-unlock-revalidate discipline, unreadable counters treated as UNKNOWN, and log-based sweep health. Restore reconstructs the active generation from the outbound policy's reqid (kernel truth); no transition state is persisted across restarts. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: treat xfrm AddTime as the absolute install timestamp it is Staging M9 showed the housekeeping sweep deleting a restart-orphaned pending generation on its first pass: the orphan-aging check multiplied AddTime (kernel curlft.add_time, seconds since the EPOCH) as if it were an age, so every orphan trivially exceeded the 5-minute protection window -- racing exactly the transition the deadline exists to protect. The policy heal's newest-generation pick had the same inversion. Age is now computed as Now - AddTime and newest means the largest timestamp; verified on staging: the orphan survives the +1min check after a restart and is swept at the deadline (journal: "esp sweep reaped/deleted" at +5-6min). Co-authored-by: Cursor <cursoragent@cursor.com> * docs: update TERMINAL runbook for terminal-owned guards and confirmed holder death The Mac agent's TERMINAL procedure now installs live holders' drop guards in a terminal-owned pf anchor that per-VM cleanup cannot flush, confirms each holder VM's death before removing its guard (unconfirmed death quarantines with guards intact; resume is idempotent), and adds the headroom-unknown deadline as a terminal trigger. All holder-bearing teardowns funnel through the same procedure. Co-authored-by: Cursor <cursoragent@cursor.com> * lib: bound SPI-to-int conversions for netlink fields Co-authored-by: Cursor <cursoragent@cursor.com> * lib: convert SPIs to int in provably in-range halves CodeQL cannot credit a math.MaxInt guard (legitimate SPIs exceed MaxInt32), so split the conversion into two 16-bit halves that fit int on any platform, and add a compile-time assertion that fails 32-bit builds outright. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 82cf118 commit 1f0019c

10 files changed

Lines changed: 3554 additions & 10 deletions

docs/ESP_OPERATIONS.md

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# ESP operations for Mac static-IP IPIP tunnels
2+
3+
Operational notes for the ESP layer on the IPIP outer path. Audience:
4+
whoever is on call when a vprox deploy or the Mac static-IP feature needs
5+
attention in a hurry.
6+
7+
**ESP is MANDATORY on `/connect-ipip`, and the wire protocol is versioned.**
8+
Every request must be `{"version":1,"op":...}` with op one of
9+
`connect | prepare | activate`; anything else — including the empty body,
10+
`{}`, and every legacy `{"esp":...}` shape — is rejected with HTTP 400
11+
BEFORE any peer lookup or mutation. There is no plaintext IPIP, no request
12+
flag to ask for it, and no environment override on the agent (the old
13+
test-only `BLACKSMITH_STATIC_IP_ALLOW_PLAINTEXT` hatch is gone; tests use
14+
an in-process seam). This is safe because `/connect-ipip` has never
15+
carried production traffic — prod vproxes 405 it — so there are no
16+
deployed clients to stay compatible with. The WireGuard `/connect` path is
17+
unaffected.
18+
19+
## The forward-only rotation contract (no rollback)
20+
21+
The SAs have no lifetimes and no ESN (macOS setkey cannot install ESN), so
22+
a non-ESN SA hard-stops at sequence 2^32; the Mac agent's tunnel actor
23+
rotates SA generations hourly. Generations move FORWARD ONLY — no SA is
24+
ever deleted and re-added (a re-added outbound resets its sequence counter
25+
while the peer's anti-replay high-water mark survives: instant permanent
26+
blackhole on a mature tunnel), and there is **no abandon, no revert, no
27+
rollback of any kind**:
28+
29+
```text
30+
stable N → prepared N+1 → activated N+1 → proven → switched → stable N+1
31+
32+
failure before ACTIVATE → reap pending, retry at next hourly tick (N untouched)
33+
proof/switch failure INCONCLUSIVE → nothing destructive; re-prove next tick
34+
proof/switch failure DEAD → exactly ONE forward retry (fresh N+2, in-tick)
35+
retry also DEAD → TERMINAL
36+
```
37+
38+
Wire ops:
39+
40+
* `{"version":1,"op":"connect"}` — destructive fresh install (new tunnel).
41+
* `{"version":1,"op":"prepare"}` — mint + install generation N+1 on the
42+
vprox side without flipping the outbound policy; keys returned over TLS.
43+
* `{"version":1,"op":"activate","target":"<hex>","expectedActive":"<hex>"}`
44+
**fenced** compare-and-swap flip of the outbound policy: it succeeds
45+
only if the policy currently selects `expectedActive`'s generation. A
46+
delayed/replayed activate for a superseded generation gets HTTP 409, a
47+
body naming the real active generation, and mutates NOTHING. Activating
48+
the already-active target is idempotent 200.
49+
50+
Server-side failure handling is ONE housekeeping sweep (every 5 s,
51+
extending the vanished-peer reaper): reap prepared-but-never-activated
52+
generations after ~5 min (`VPROX_ESP_PENDING_DEADLINE`), counter-gated GC
53+
of superseded generations after the client provably transmits on the new
54+
one plus a ~2 min grace (`VPROX_ESP_GC_GRACE`), orphan aging, and
55+
vanished-peer reaping. The sweep never touches the active generation, a
56+
pending within deadline, or anything a transition references, and treats
57+
an unreadable counter as UNKNOWN (skip), never as zero. Sweep health is
58+
log-based: `ipip housekeeping healthy` every ~10 min plus immediate error
59+
lines.
60+
61+
There are no per-rekey goroutines, no epochs, no activation gate, no
62+
auto-revert: the fenced activate plus the sweep replace all of it.
63+
64+
## Restart semantics
65+
66+
`RestoreIpipFromKernel` adopts leftover tunnels and reconstructs each
67+
pair's ACTIVE generation from kernel truth — the outbound policy's
68+
template reqid IS the active generation's SpiToClient. No transition state
69+
is persisted: orphan pending states are swept by the housekeeper after
70+
their deadline, and the client's fenced activate carries its own
71+
`expectedActive`, so a restarted vprox answers it correctly with no
72+
memory. **A restart mid-transition also loses the Mac agent's one-recovery
73+
budget** (it lives on the actor goroutine's stack, deliberately never
74+
persisted), so the next evaluation after a crash-loop may go TERMINAL
75+
instead of retrying — accepted: a tunnel that keeps crashing mid-rotation
76+
should die loudly, not wobble.
77+
78+
## TERMINAL (replaces rollback, strikes, and seppuku)
79+
80+
When a tunnel cannot rotate — a rotation convicted DEAD twice within one
81+
recovery budget, the sequence-headroom audit crossing 2^31
82+
(`BLACKSMITH_STATIC_IP_SEQ_TERMINAL_THRESHOLD` overrides for tests) while
83+
rotation is blocked, headroom staying UNPROVABLE (counter reads failing)
84+
continuously past `BLACKSMITH_STATIC_IP_HEADROOM_UNKNOWN_WINDOW` (default
85+
6 h), or vprox reporting the pair rebuilt (`Fresh`) — the Mac agent runs
86+
the TERMINAL procedure. Every teardown of an entry that still has holder
87+
VMs funnels through the same procedure (including a cached tunnel probed
88+
DEAD and a quarantined entry being retried); raw teardown is only legal
89+
at zero holders.
90+
91+
1. stop admissions on the tunnel entry and drain ATTACHING holders (VMs
92+
whose setup is still in flight: their setup revalidates the entry
93+
before committing PF rules and again before VM start, so a fenced
94+
entry fails their setup with a retryable error and the job requeues);
95+
2. join the tunnel actor (join timeout ⇒ quarantine; nothing is destroyed
96+
while the owner may still run);
97+
3. install **PF drop guards** for the exact holder set and kill their pf
98+
states, VERIFYING the drops took effect **before** the gif is
99+
destroyed. Live holders get TWO copies: one in a TERMINAL-owned anchor
100+
(`blacksmith-sip/term-<vmid>`) that per-VM cleanup never touches, and
101+
one replacing the per-VM anchor's rules in place. This is fail-closed
102+
by construction: the per-VM `route-to` rule is stateful and the
103+
baseline mac policy broadly allows VM internet, so without guards a
104+
destroyed gif would let VM traffic egress `en0` under the mini's own
105+
source IP — an allowlist-contract violation worse than the outage. If
106+
a guard cannot be verified, the entry is quarantined with the gif
107+
intact instead.
108+
4. terminate every live holder VM through the VM-stop machinery with the
109+
distinct `StaticIPTunnelLost{VMID, JobID, VproxIP}` reason (StopVM
110+
errors are NOT success);
111+
5. CONFIRM each holder VM's death (bounded poll of the VM object's state
112+
/ manager ownership). A holder's terminal-owned guard is removed only
113+
after THAT holder is confirmed gone; any unconfirmed holder ⇒
114+
quarantine with all guards intact. The next acquisition resumes the
115+
procedure idempotently (guards may already exist; re-verify, continue);
116+
6. tear down (gif destroyed + verified absent, IPsec flushed, entry
117+
removed); the next acquisition builds fresh;
118+
7. emit `blacksmith_vm_static_ip_terminal` (reason + vprox_server_ip) and
119+
`blacksmith_vm_static_ip_terminal_jobs`.
120+
121+
**Product decision (approved, do not regress): jobs terminated by a
122+
TERMINAL event are NOT automatically rerun.** This is the same contract as
123+
host death. The termination is attributed as infrastructure (never a
124+
customer failure) and the terminal counter pages, so the attribution is
125+
loud; replaying the affected work is a human/product decision, not agent
126+
behavior. Documented in `jobsbase.StaticIPTunnelLost` and the vm-agent
127+
alert rules.
128+
129+
## Observability and the canary plan
130+
131+
Alerting lives in the FA repo
132+
(`agent/grafana/alerts/infrastructure/vm-agent.yaml`):
133+
134+
* rekey-overdue on `blacksmith_vm_static_ip_rekey_last_success_age_seconds`
135+
— warn at 2 h, page at 3 h (a genuinely failing tunnel goes terminal
136+
within one tick, so a climbing age means a permanent-INCONCLUSIVE
137+
environment or a wedged actor);
138+
* `blacksmith_vm_static_ip_rotation_attempts{phase,outcome}` — every
139+
rotation phase outcome; the `outcome="dead"` delta alerts as the early
140+
warning;
141+
* `blacksmith_vm_static_ip_terminal`**page-level**; every terminal
142+
event pages with its reason and vprox attribution.
143+
144+
These metrics are the CANARY for wide enablement: run the fleet at
145+
staging/limited scope and measure the real-world p(dead)/rotation and
146+
p(terminal)/rotation from `rotation_attempts` and `terminal` before
147+
enabling more orgs. A terminal rate visibly above the vprox-host incident
148+
rate means the proof/switch path is misfiring and enablement must pause.
149+
150+
vprox itself has no prometheus; its sweep health, fence rejections
151+
(`esp activate FENCED`), and reap/GC decisions are structured log lines in
152+
the vprox journal.
153+
154+
## Emergency handling
155+
156+
A binary downgrade alone is NOT a rollback: ESP state lives in the kernel,
157+
not the process (SAs and require-ESP policies survive a vprox restart by
158+
design, and the minis hold matching state). **The emergency brake is
159+
stopping static-IP admission, not downgrading.** If ESP breaks fleet-wide:
160+
161+
1. Stop admitting new Mac static-IP jobs.
162+
2. **vprox box** — if the binary must move, the deploy playbook
163+
(`setup_vprox_server.yaml`) preserves the previous binary as
164+
`~/vprox.bak-<date>`; copy it back over `~/vprox/vprox` and
165+
`systemctl restart vprox`. Then flush kernel ESP state:
166+
167+
```
168+
ip xfrm state flush
169+
ip xfrm policy flush
170+
```
171+
172+
(Flushes ALL xfrm on the box; vprox's WireGuard path does not use
173+
xfrm, so on a vprox gateway this is safe.)
174+
3. **Each affected Mac mini**`setkey -F` (SAs) and `setkey -FP`
175+
(policies), then let the agents rebuild through the normal setup path.
176+
177+
Cross-revision skew within the rotation protocol is NOT supported: the
178+
explicit `version` on every request rejects a mismatched pair with a 400
179+
before any state is touched, so a half-deployed fleet fails setup cleanly
180+
instead of degrading. Deploy both sides from matching revisions and keep
181+
the feature dark until both are live.
182+
183+
## Accepted threat model
184+
185+
The ESP keys are minted by vprox and delivered to the Mac helper inside
186+
the `/connect-ipip` HTTPS response. That TLS channel uses vprox's embedded
187+
self-signed certificate and the client dials with certificate verification
188+
disabled (`InsecureSkipVerify`), authenticating itself with the shared
189+
bearer password. Consequences, accepted deliberately:
190+
191+
* **Protected:** passive observation of the LAN/data-center path. All
192+
tunnel payload is AES-CBC + HMAC-SHA256 ESP; an observer sees only
193+
ESP frames, and the key material rides inside TLS.
194+
* **Accepted risk:** an active man-in-the-middle on the same LAN at
195+
handshake time can terminate the skip-verify TLS connection, capture the
196+
bearer password, and mint/relay keys — i.e. read or alter tunnel traffic
197+
for sessions it intercepted from the start. This is the exact bootstrap
198+
trust model of the existing WireGuard `/connect` path (same skip-verify
199+
TLS, same bearer password), so ESP adds protection without weakening
200+
anything that exists today.
201+
* **Future fix if scope changes:** pin the embedded vprox certificate in
202+
the Mac agent (the cert ships in the vprox binary already; the agent
203+
would verify the presented leaf against the pinned one instead of
204+
skipping verification). Worth doing before extending static IP beyond
205+
same-datacenter LANs.
206+
207+
Related invariants the code maintains (do not regress):
208+
209+
* Key material never appears in argv, logs, or error strings (helpers read
210+
secrets from stdin JSON; outputs are hex-redacted; transition keys live
211+
only in the actor's memory for the life of one transition).
212+
* SA generations are FORWARD-ONLY, and ambiguity is resolved by READ-ONLY
213+
inspection of kernel truth (the helper's `report` mode on the Mac, the
214+
fenced CAS on vprox) followed by a retry of the SAME target — never by
215+
minting a fresh generation for an existing transition, and never by
216+
deleting the target "to be safe".
217+
* The housekeeping sweep and the Mac GC step are counter-gated: nothing
218+
superseded is deleted until the new generation provably carries packets,
219+
plus a grace period.
220+
* Restart adoption (`RestoreIpipFromKernel`) never touches the xfrm state
221+
of adopted pairs — including when it deletes a rejected leftover iface
222+
that shares its remote with an adopted tunnel.
223+
* PF fail-closed before gif destruction: any teardown path where VMs may
224+
still reference the gif installs and verifies drop guards first (for
225+
live holders, in a TERMINAL-owned anchor that survives per-VM cleanup),
226+
and destroys nothing until every holder's death is confirmed.

0 commit comments

Comments
 (0)