Repository navigation
feat(cloud): carry the phones' sessions through FTW Cloud, linked from pairing - #1635
Conversation
…m 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
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>
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>
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. |
…ng 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>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a5e046910
ℹ️ 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".
| func (r cloudRoster) Pending() ([]byte, time.Time, bool) { | ||
| if !r.tunnel { | ||
| return nil, time.Time{}, false | ||
| } | ||
| return r.enroll.PendingCode() |
There was a problem hiding this comment.
Include the Cloud home in the pairing payload
When a brand-new phone scans this QR while the relay is unavailable, publishing the code hash does not let it reach FTW Cloud: appenroll.EnrollmentURL still emits only the box Noise key, pairing code, LAN hint, and rendezvous secret, while the Cloud home is exposed only by SessionInfo after an already-successful box session. The phone therefore cannot form /v1/app?home=<id>, so the advertised Cloud pairing fallback cannot start; include the home in the scanned material or provide another pre-session derivation.
AGENTS.md reference: AGENTS.md:L24-L25
Useful? React with 👍 / 👎.
| if p == nil || p.kind != PairingKindQR || !i.now().Before(p.expiresAt) { | ||
| return nil, time.Time{}, false |
There was a problem hiding this comment.
Schedule link teardown when the pairing code expires
When an unpaired box links solely because it displayed a QR code and that code expires unused, this branch begins returning false but fires no roster callback. endIfIdle is only called when rosterDue is set, not during the six-hour renewal, and the persisted link makes restarts reconnect as well; consequently cloud.key, the home, and the outbound Cloud connection remain indefinitely despite no phone or grant needing them. Schedule a wakeup at expiresAt or re-evaluate idleness during renewal.
Useful? React with 👍 / 👎.
| l.mu.Lock() | ||
| l.tunBudget.add(l.uptimeMs(), int64(len(frame))) | ||
| l.mu.Unlock() |
There was a problem hiding this comment.
Update the last-send timestamp for tunnel frames
When the readable copy is off but a phone actively uses the tunnel, this writer records bytes without updating lastSend. Since Status.LastSendAgeMs is documented as the age of the last thing sent, it instead ages from the last roster—potentially for six hours—or remains -1, making a healthy active link appear stale while TunBytesLast24h rises. Update lastSend after a successful tunnel write.
AGENTS.md reference: AGENTS.md:L22-L23
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>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
… 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>
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>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
…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>
* feat: carry each phone's session to its box, and survive a restart
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>
* fix: review of the tunnel (B, A, D, C and E-N, K, L)
- 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>
* test: the readable copy turns on and off on a live uplink
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>
* chore: pin go/protocol to srcfl/ftw master after #1635 (f5cdb920)
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>
* Privacy: FTW Cloud as the app's one door, the readable copy, the relay while we move The app and the watch reach the box through FTW Cloud in Stockholm. From the FTW release that carries srcfl/ftw#1635, every box with a paired phone links to it, so the page now says what FTW Cloud sees for every home (a fixed home id, the box's and phone's addresses, the signed device list, timing), what it keeps on disk, and what the optional readable copy holds. The Frankfurt relay stays as a fallback while clients move. Assistants and support access are named as later work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Privacy: dated the day FTW 0.144.0-beta.1 went out 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 (Fredrik's decision on #1632, 15 October 2026), the
srcfl/ftwhalf. Paired with srcfl/ftw-cloud#15, which pins this branch'sgo/protocol. Merge this first, then move that pin to the merge commit.A phone can now reach its box through FTW Cloud with the relay unreachable. The relay keeps working as before, and the box keeps dialing it.
What changes
Wire (
go/protocol/cloudwire,docs/protocol-cloud.md"Tunnel")tun {sid, d}on/v1/app,tun {dev, sid, d}on/v1/box, both ways.dis the phone'sftw.session.v1session with the box, byte for byte.tuncarries no header and no signature: Noise authenticatesd, and the service reads onlydevandsid.tuncarries ciphertext the box decrypts and judges as it judges the same session over the relay.tunbit andpendingpairing hashes. The grant has three shapes: on (id +serve), withdrawn (id, no scopes or purposes) and off (no id).purgedgainswhat: "copy".now.pairlets a new phone prove a code's hash;now.tuncarries the box session's message 1, so that session is up one round trip after TLS.-updatea message whose signed input did not change now keeps its signature; the existingrostervector did change (it gainedtunandpending), so it was re-signed.Core
cloudlink: the box links when it first shows a QR code or has a phone that may tunnel, independent of the readable grant. With the copy off it sends its roster and the tunnel and nothing to read (hbincluded). Revoking the grant now deletes the copy at the service and keeps the link and home id; the link ends (bye {purge}, key deleted on the receipt) only when nothing needs it. A grant made before the link existed hands its key to the link, so existing homes keep their id.tungoes through aTunnelport, socloudlinkstill imports onlygo/protocol(make cloudlink-boundary). It has two queues and its own budget of 250 MB a day; it neither counts against nor is stopped by the copy's 20 MB. A lane 0 frame (a command, its answer, a tick) goes first and is never held; bulk is paced to 90 % of the service's limits (cloudwire.TunMsgBurstand friends), so a command's answer never waits behind bulk and the service never drops for this box.appuplink: a tunnel table routestunby device and session, and answers at most 12 handshakes a minute per device and 240 in all, so one phone or one pairing cannot starve the others. The box admits a session exactly as over the relay (known key, or the pairing code inside the handshake), and drops a handshake whose proven key is not the device the service named. Each device now holds one slot across the relay and the tunnel, in place of four anonymous places.appenroll:v3QR builder behindQRNamesHome(off). The live QR code is listed bySHA-256("ftw.cloud.pair.v1\0" ‖ home ‖ code). Spoken codes (40 bits) and watch codes never are.GET /api/cloudaddslink,tunBytesLast24h,tunBudgetBytesandtunDropped.Review fixes
appenroll.ChecksplitsAuthorise), take the device's slot and replace the live session. Both carriers.TestAReplayedMessage1LeavesTheLiveSession,TestAReplayedMessage1LeavesTheLiveRelaySession.takeRoster), so a revoke that lands between a snapshot and the roster goes in that roster.TestTheRosterReadsTheLinkAsItIsWhenDue.tunis counted and the session goes on; only a lost uplink fails it.acceptCmdforgets the id when its ack cannot leave, so a retry acts.TestADroppedTunDoesNotEndTheSession,TestAnAckThatCannotLeaveForgetsTheCommand.hello.capsandgrantIddescribe the connection's start; the protocol now says the roster's grant governs mid-connection.TestTheCopyTurnsOnAndOffOnALiveConnectionhere; feat: carry each phone's session to its box, and survive a restart ftw-cloud#15 has the service side.TestAnUnusedCodeEndsTheLinkWhenItRunsOut.ErrLinkEndinganswers 409.session.setRelease).appuplink.CloudRoster, whichcmd/ftwnow uses too; the budget assertion is exact; the copy flows past the tunnel's budget;Uplink.DropSessionsByAppKeyreaches the tunnel.cloud.keyis never overwritten:createKeyusesos.Link, and a key on disk is adopted, so its home id stays.TestAKeyOnDiskIsAdoptedNeverReplaced.sidbut its current, and never restart on it.Proof
TestACommandThroughFTWCloudWithTheRelayGone(appuplink): the realcloudlink, tunnel table andappprotohandler, the relay endpoint pointed at a closed port, and a minimal FTW Cloud in the test. A phone in the roster sendscmd; the box applies it and answerscmd.ackandcmd.result. A key the roster does not list gets nothing forwarded. With the grant off onlyrosterandtunleave the box, andhello_ok.cloudnames the home with empty tables.TestAForgedDevGetsNothing,TestAStrangerThroughTheTunnelGetsNothing,TestPairingThroughTheTunnel,TestOneSessionPerDevice,TestTheRelayAndTheTunnelShareADevicesSlot,TestARevokeEndsATunnelSessionand others inappuplink/tunnel_test.go.cloudlink/tunnel_test.go: linking without readings, the pending hash,tunboth ways and apart from the budget, the drop with the uplink, the end of a link, and a grant from before the link keeping its home.go test -raceonappuplinkandcloudlink(×3),make test,make verify.Measured round trip through a real local
ftwcloudd(this branch's pair), with the box in process (realcloudlink, tunnel and handler), the relay unreachable, on one laptop over loopback, 100 commands one every 100 ms:cmdtocmd.ackcmdtocmd.resultRe-measured after the review fixes (pending sessions, two queues):
cmdtocmd.ackmedian 0.57 ms, p95 1.5 ms; box session up median 3.9 ms after dialling, 1.8 ms of it TLS.Over real networks the time to an ack is then the two round trips, phone to
eu-north-1and box toeu-north-1, plus about a millisecond. Fredrik's target is 200 ms; nothing on this path waits for a timer or a cadence.Before this ships
cloud.ftw.energy. For every linked home the service sees a stable home id, the box's address and when traffic goes.VISION.mdsays so; the privacy notice must too before a beta.v3isv2plus a sixth segment, the home id in base64url, defined inappenrollanddocs/protocol-cloud.mdand tested. One constant,appenroll.QRNamesHome, turns it on. Step-2 dependency: flip it only once ftw-app (Enrollment.swiftand Android'sEnrollment.kt) and ftw-webapp (enrollment.ts) parsev3in builds people have; each takes exactly fivev2segments today.TestTheQRStaysV2UntilTheAppsParseV3holds the gate. Until then a new phone learns the home id fromhello_ok.cloudon its first session, which the box fills from its first pairing, grant or not. The pending pairing entry in the roster lands now.minor, which needs Fredrik's approval.Not in this PR
POST /v1/app/xfor the watch (step 3).docs/adr/(docs(adr): propose FTW Cloud as the single remote entry #1632 has right of way). feat(appuplink): point the command uplink at FTW Cloud #1633 is untouched.🤖 Generated with Claude Code