Repository navigation
feat: carry each phone's session to its box, and survive a restart - #15
Conversation
Step 1 of the one-door plan, the ftw-cloud half; pairs with srcfl/ftw
feat/cloud-tunnel, whose go/protocol this pins.
- tun: a phone's tun {sid, d} on /v1/app goes to its box as
tun {dev, sid, d}, with dev from the roster entry the service's own
handshake matched; the box's answers go back to that device's session.
Routed per device on queues of their own, never as a held class. Only a
box that lists tun in hello.caps gets any. An empty d ends a session at
either end, and the service sends one when a phone's app session or the
box's uplink ends. now.tun lets the box session ride in message 1.
- Admission: a roster device needs tun or a readable copy. A pending
pairing hash admits one new phone, which may only tunnel and takes its
roster entry when the box pairs it.
- Limits: per home and direction 240 tun then 60/s and 4 MiB then
512 KiB/s, dropped and counted past that; frames up to MaxTunFrame;
Origin https://app.ftw.energy allowed on /v1/app.
- The copy: readings are held only while the grant serves; a withdrawn
grant deletes the copy and the tokens and gets purged {what: copy}.
- Before commands move here: rosters are read back from the registry at
start; box.unreachable waits survive a restart; a connection becomes the
home's only after hello_ok, so watch or tun never precede it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2ac73e402
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| h.roster, h.rosterAt, h.lastSeen = r, at, at | ||
| h.setPendingLocked(r, at) |
There was a problem hiding this comment.
Persist spent pairing hashes across restarts
When the service restarts while a still-valid roster contains a pairing hash that was already consumed, Seed reconstructs pending from that roster but the newly created home's spent map is empty. The same supposedly one-use code can therefore admit another device until its TTL expires, potentially allowing a second phone to pair and control the home. The consumed state needs to survive a restart or otherwise remain excluded when restoring the roster.
Useful? React with 👍 / 👎.
| if x.h.conn == conn { | ||
| x.h.online = false | ||
| x.h.lastSeen = x.s.now() | ||
| x.h.tunGoneLocked() |
There was a problem hiding this comment.
Notify phones when replacing an uplink
When a second uplink for the same home overlaps the first, the new connection calls Connected and increments h.conn before closing the old socket. The old connection's deferred Disconnected then fails this generation check, so tunGoneLocked is never called for tunnel sessions carried by the replaced uplink. If the replacement comes from a restarted box process that no longer has those Noise sessions, affected phones receive no empty tun and can wait indefinitely for responses; replacement should explicitly end the old tunnel sessions.
Useful? React with 👍 / 👎.
… device Review of srcfl/ftw-cloud#15 (L) and #1635 (P): - The box's tun goes in two queues. A lane 0 frame (a command, its answer, a tick) goes first and is never held; bulk is paced to nine tenths of the service's limits, now shared as cloudwire.TunMsgBurst and friends, so the service never drops for a box that keeps to them. A command's answer never waits behind bulk. - The tunnel answers at most 12 handshakes a minute per device and 240 in all, so one phone or one pairing cannot starve the others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
- B: a box that dials again ends the tunnels its older uplink carried, so phones hear it even while the old socket is still open. - A: a box sending tun at twice the home's limits is closed; under that the service drops and counts. The limits are now cloudwire's, shared with the box, which paces its bulk under them (srcfl/ftw#1635). - D: readings go from memory whenever a roster does not serve. - E, F, M: a restart does not restore pairing codes; a key the roster lists never enters by pairing; the hash is spent only when the session opens. - G: a roster that takes a device's tunnel away ends its session. - H, N: the box hears the end of a phone's tunnel however the app session ends; a d larger than any session message closes the phone with 4400. - I: push hooks carry the uplink's connection number, so the late end of an older connection never starts box.unreachable for a box that is back. - K: suspensions are read from memory, so admission under a home's lock never waits for SQLite. - L: dropped tun is logged once a minute as a count for the whole service. - C: the guide and README say what holds now, and list each part's size; the budgets are Fredrik's call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Review of srcfl/ftw#1635, point 4: hello's caps and grantId describe the connection's start; the service takes what it may hold from the newest roster's grant. The README says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
…m pairing (#1635) * feat(cloud): carry the phones' sessions through FTW Cloud, linked from pairing Step 1 of the one-door plan, the srcfl/ftw half. A phone reaches its box through FTW Cloud with the relay unreachable. - cloudwire: tun {sid, d} on /v1/app and tun {dev, sid, d} on /v1/box, unsigned and without a header; d is the ftw.session.v1 session byte for byte. The closed downlink grows to six. The roster gains a per-device tun bit and pending pairing hashes, and the grant gets an off and a withdrawing shape; purged gains what: copy. Vectors, allowlist, fuzz and docs/protocol-cloud.md ("Tunnel") follow. - cloudlink: the box links at its first QR code or tun-capable phone, independent of the readable grant, and ends the link when nothing needs it. Revoking the grant deletes the copy and keeps the link and home id. tun goes through the Tunnel port unread, with its own queue and a budget apart from the copy's 20 MB; hb and readings go only with the copy on. - appuplink: the tunnel table routes tun by device and session, checks the handshake's key against the device the service named, and admits as over the relay. Each device holds one session across relay and tunnel, in place of four anonymous places. - appenroll/cmd: the live QR code is listed by hash; spoken and watch codes never are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * style: gofmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * feat(cloudwire): the box session rides in the app session's message 1 now.tun carries message 1 of the phone's session with its box, so the service forwards it as soon as it admits the phone and that session is up one round trip after TLS. Vector and protocol text follow; the protocol admission rule reads "with tun, or while the readable copy is on". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * docs(cloudwire): a phone draws a new sid for each box session A late end of an older session must never end a newer one under the same number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * feat(appenroll): define the v3 QR that names the home, and keep showing v2 v3 is v2 with a sixth segment, the home id at FTW Cloud in base64url, so a new phone knows where to dial before any session with the box. Every shipped parser (ftw-app Enrollment.swift and Enrollment.kt, ftw-webapp enrollment.ts) takes exactly five v2 segments, so the box keeps showing v2 until one constant, appenroll.QRNamesHome, turns v3 on in step 2. A new phone learns the home id from hello_ok.cloud meanwhile, which the box sends from its first pairing. docs/protocol-cloud.md specifies v3 and the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * feat(cloud): lane 0 ahead of paced bulk on the tunnel; handshakes per device Review of srcfl/ftw-cloud#15 (L) and #1635 (P): - The box's tun goes in two queues. A lane 0 frame (a command, its answer, a tick) goes first and is never held; bulk is paced to nine tenths of the service's limits, now shared as cloudwire.TunMsgBurst and friends, so the service never drops for a box that keeps to them. A command's answer never waits behind bulk. - The tunnel answers at most 12 handshakes a minute per device and 240 in all, so one phone or one pairing cannot starve the others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * fix(cloud): review of #1635 — a session proves itself before it takes a place - A new session, through FTW Cloud or the relay, waits pending until its first transport message decrypts; only then does it commit the pairing or the reconnect stamp (appenroll.Check), take the device's slot and replace the live session. A replayed message 1 changes nothing. - The roster is built from the link as it is when due, so a revoke that lands mid-tick goes in that roster. - A dropped tun is counted and the session goes on; acceptCmd forgets an id whose ack could not leave. - An unused pairing code that runs out ends an idle link; ErrLinkEnding is 409; the slot's release is set without a race; cloud.key is adopted and never overwritten; each tunnel handshake runs on its session's goroutine. - The roster adapter moves to appuplink.CloudRoster for cmd/ftw and the end-to-end test; new tests for each point; protocol text for pending sessions, hello's fields and how a phone treats an end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> * feat(cloud): say cloud.tun at hello when the link carries the tunnel (#1639) A phone that knows the home id goes through FTW Cloud only to a box that said cloud.tun at its last hello, so an older box costs it no wait. The registry copy also gains the carrier state tunnel. Contract pair: srcfl/ftw-webapp claude/registry-cloud-tunnel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 1 of the one-door plan, the
ftw-cloudhalf. Paired with srcfl/ftw#1635:go.modpins that branch'sgo/protocol(0970e7b3). After it merges, move the pin to its merge commit. Deploy this before any Core beta that carries srcfl/ftw#1635: those boxes send rosters with the readable copy off, which the running service refuses.What changes
The tunnel
tun {sid, d}on/v1/appgoes to its box astun {dev, sid, d}.devcomes from the roster entry the service's own handshake matched; adeva phone adds is ignored. The box's answers go back to that device's session.dis never read.store.Session.Tun), never held as a class, so a reading never waits behind the tunnel or the other way round.tuninhello.capsgets any, since an older box closes on a type it does not know.dends a session. The box hears when a phone's app session ends; the phone hears when the box's uplink ends, or when no box takes its message.now.tuncarries the box session's message 1, forwarded the moment the phone is admitted, so that session is up one round trip after TLS.Admission and pairing
tunor a readable copy to be admitted.now.pairand is admitted once, as a device that may only tunnel. When the roster lists it, its session takes that entry and stays open.Limits
tunin a burst then 60 a second, 4 MiB then 512 KiB a second, as the relay allows one stream. Past that the service drops and counts; Noise transport survives a lost message.tundoes not count against the box's or the phone's message rate. A phone sending twice the home's allowance closes.MaxTunFrame./v1/applets inOrigin: https://app.ftw.energy, no other.The readable copy
purged {what: "copy"}, again for each such roster. The home, its roster and its tunnel stay.Before commands move here (review §3.5)
uplink.Restore).box.unreachablewaits survive a restart (awaytable,push.Resume): an outage keeps what was left of its wait, a sent alert is not repeated, and a box connected at shutdown gets the whole wait.hello_okhas gone, sowatchortunnever precede it.TestNothingReachesABoxBeforeHelloOKfails with the old order.Proof
internal/server/tunnel_e2e_test.go, the whole service behind TLS 1.3 asftwclouddwires it, with a Go box that answers the phone's real Noise session:TestACommandThroughTheServiceGetsTheBoxsAnswer: a phone in the roster sendscmdand getscmd.ackandcmd.result; the copy is off and the snapshot is empty. 20 runs: median 0.54 ms, max 0.88 ms.TestTheBoxSessionRidesInMessage1TestAPhoneNotInTheRosterIsRefusedBeforeAnythingIsForwarded: 4403 before message 2, and the box receives nothing.TestAForgedDevIsIgnoredTestTheCopyStaysOffWhileTheTunnelWorksTestAPhonePairsThroughTheService,TestEachEndHearsTheOtherGo,TestOnlyTheWebAppsOriginIsLetInuplink/tunnel_test.go,push/resume_test.go, fuzz targets fortunfrom a phone and a box.cloudlink, tunnel and handler against this branch'sftwclouddbinary on loopback, relay unreachable:cmdtocmd.ackmedian 0.67 ms, p95 3.5 ms; box session up 2.2 ms after dialling.go vet ./... && go test -race ./...,go test -tags ftwclouddev ./...,scripts/check-imports.sh.Review fixes (462813d)
TestABoxThatRedialsEndsThePhonesOldTunnel).tunlimits is closed; under that the service drops and counts (TestABoxFloodingTunIsClosed). The limits are nowcloudwire.TunMsgBurstand friends, shared with the box, which since feat(cloud): carry the phones' sessions through FTW Cloud, linked from pairing ftw#16350970e7b3paces its bulk to 90 % of them and sends lane 0 first, so the service does not drop for a box that keeps to them (L).doverTunDataMaxcloses the phone with 4400.box.unreachablefor a box that is back.tunis logged once a minute as a service-wide count; it names no home.tun,store.Seed, the queue claim); the guide lists each part's size, and the budgets are Fredrik's call (5,009 lines without tests).Review of srcfl/ftw#1635, point 4 (e897a34)
hello.capsandgrantIddescribe the connection's start; the service takes what it may hold from the newest roster's grant.TestTheCopyTurnsOnAndOffMidConnectionruns off → on → off on one live uplink.Step-2 dependency
The QR stays
v2(srcfl/ftw#1635,appenroll.QRNamesHome, off) until ftw-app (iOS and Android) and ftw-webapp parsev3, the QR that names the home. Pairing through this service with the relay blocked needs that. The pending roster entry andnow.pairadmission land here now and are tested.Not in this PR
POST /v1/app/xfor the watch (step 3).Supersedes the direction of #14, which ports the relay's room protocol; Fredrik asked for that to pause.
🤖 Generated with Claude Code