Skip to content

feat(cloud): carry the phones' sessions through FTW Cloud, linked from pairing - #1635

Merged
frahlg merged 11 commits into
masterfrom
feat/cloud-tunnel
Oct 15, 2026
Merged

frahlg merged 11 commits into
masterfrom
feat/cloud-tunnel

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Step 1 of the one-door plan (Fredrik's decision on #1632, 15 October 2026), the srcfl/ftw half. Paired with srcfl/ftw-cloud#15, which pins this branch's go/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. d is the phone's ftw.session.v1 session with the box, byte for byte. tun carries no header and no signature: Noise authenticates d, and the service reads only dev and sid.
  • The closed downlink grows from five types to six. The first five still ask the box for nothing. tun carries ciphertext the box decrypts and judges as it judges the same session over the relay.
  • The roster gains a per-device tun bit and pending pairing hashes. The grant has three shapes: on (id + serve), withdrawn (id, no scopes or purposes) and off (no id). purged gains what: "copy".
  • now.pair lets a new phone prove a code's hash; now.tun carries the box session's message 1, so that session is up one round trip after TLS.
  • Allowlist, golden vectors and the downlink fuzz follow. The new vectors are appended. Under -update a message whose signed input did not change now keeps its signature; the existing roster vector did change (it gained tun and pending), 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 (hb included). 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.
  • tun goes through a Tunnel port, so cloudlink still imports only go/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.TunMsgBurst and friends), so a command's answer never waits behind bulk and the service never drops for this box.
  • appuplink: a tunnel table routes tun by 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: v3 QR builder behind QRNamesHome (off). The live QR code is listed by SHA-256("ftw.cloud.pair.v1\0" ‖ home ‖ code). Spoken codes (40 bits) and watch codes never are.
  • GET /api/cloud adds link, tunBytesLast24h, tunBudgetBytes and tunDropped.

Review fixes

  • 1. A replayed message 1 takes no device's place. A new session waits, pending, until its first transport message decrypts; only then does it commit the pairing or the reconnect stamp (appenroll.Check splits Authorise), take the device's slot and replace the live session. Both carriers. TestAReplayedMessage1LeavesTheLiveSession, TestAReplayedMessage1LeavesTheLiveRelaySession.
  • 2. The roster is built from the link as it is when it is due (takeRoster), so a revoke that lands between a snapshot and the roster goes in that roster. TestTheRosterReadsTheLinkAsItIsWhenDue.
  • 3. A dropped tun is counted and the session goes on; only a lost uplink fails it. acceptCmd forgets the id when its ack cannot leave, so a retry acts. TestADroppedTunDoesNotEndTheSession, TestAnAckThatCannotLeaveForgetsTheCommand.
  • 4. hello.caps and grantId describe the connection's start; the protocol now says the roster's grant governs mid-connection. TestTheCopyTurnsOnAndOffOnALiveConnection here; feat: carry each phone's session to its box, and survive a restart ftw-cloud#15 has the service side.
  • 5. A pairing code that runs out unused ends the link when nothing else needs it. TestAnUnusedCodeEndsTheLinkWhenItRunsOut.
  • 6. ErrLinkEnding answers 409.
  • 7. The slot's release is set without a race (session.setRelease).
  • 8. The box itself refuses a stranger's handshake that the service forwards anyway; the end-to-end test uses appuplink.CloudRoster, which cmd/ftw now uses too; the budget assertion is exact; the copy flows past the tunnel's budget; Uplink.DropSessionsByAppKey reaches the tunnel.
  • P. At most 12 handshakes a minute per device and 240 in all.
  • L. Lane 0 goes ahead of bulk, which is paced to 90 % of the service's limits.
  • cloud.key is never overwritten: createKey uses os.Link, and a key on disk is adopted, so its home id stays. TestAKeyOnDiskIsAdoptedNeverReplaced.
  • Each tunnel session's handshake and its enrolment write run on the session's own goroutine, never on the table's, so routing and the 1 Hz tick do not wait for the disk.
  • The protocol says how a phone treats an end: ignore one for any sid but its current, and never restart on it.

Proof

  • TestACommandThroughFTWCloudWithTheRelayGone (appuplink): the real cloudlink, tunnel table and appproto handler, the relay endpoint pointed at a closed port, and a minimal FTW Cloud in the test. A phone in the roster sends cmd; the box applies it and answers cmd.ack and cmd.result. A key the roster does not list gets nothing forwarded. With the grant off only roster and tun leave the box, and hello_ok.cloud names the home with empty tables.
  • TestAForgedDevGetsNothing, TestAStrangerThroughTheTunnelGetsNothing, TestPairingThroughTheTunnel, TestOneSessionPerDevice, TestTheRelayAndTheTunnelShareADevicesSlot, TestARevokeEndsATunnelSession and others in appuplink/tunnel_test.go.
  • cloudlink/tunnel_test.go: linking without readings, the pending hash, tun both 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 -race on appuplink and cloudlink (×3), make test, make verify.

Measured round trip through a real local ftwcloudd (this branch's pair), with the box in process (real cloudlink, tunnel and handler), the relay unreachable, on one laptop over loopback, 100 commands one every 100 ms:

median p95
cmd to cmd.ack 0.67 ms 3.5 ms
cmd to cmd.result 0.73 ms 3.6 ms
dial to box session up (box handshake in message 1) 2.2 ms 2.5 ms

Re-measured after the review fixes (pending sessions, two queues): cmd to cmd.ack median 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-1 and box to eu-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

  • Deploy ftw-cloud first. A box on this release sends rosters with the grant off; the service running today refuses those and the box would back off and retry.
  • Privacy. From this release, every box with a paired phone dials cloud.ftw.energy. For every linked home the service sees a stable home id, the box's address and when traffic goes. VISION.md says so; the privacy notice must too before a beta.
  • The QR shows v2; v3 waits for step 2. v3 is v2 plus a sixth segment, the home id in base64url, defined in appenroll and docs/protocol-cloud.md and tested. One constant, appenroll.QRNamesHome, turns it on. Step-2 dependency: flip it only once ftw-app (Enrollment.swift and Android's Enrollment.kt) and ftw-webapp (enrollment.ts) parse v3 in builds people have; each takes exactly five v2 segments today. TestTheQRStaysV2UntilTheAppsParseV3 holds the gate. Until then a new phone learns the home id from hello_ok.cloud on its first session, which the box fills from its first pairing, grant or not. The pending pairing entry in the roster lands now.
  • The changeset is minor, which needs Fredrik's approval.

Not in this PR

🤖 Generated with Claude Code

frahlg and others added 4 commits October 15, 2026 16:23
…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T14:52:46.574546Z 0a5e046 PR opened
ℹ️ 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" or "@codex security review".

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread go/cmd/ftw/cloud_link.go Outdated
Comment on lines +300 to +304
func (r cloudRoster) Pending() ([]byte, time.Time, bool) {
if !r.tunnel {
return nil, time.Time{}, false
}
return r.enroll.PendingCode()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +543 to +544
if p == nil || p.kind != PairingKindQR || !i.now().Before(p.expiresAt) {
return nil, time.Time{}, false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread go/internal/cloudlink/conn.go Outdated
Comment on lines +403 to +405
l.mu.Lock()
l.tunBudget.add(l.uptimeMs(), int64(len(frame)))
l.mu.Unlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
- 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>
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
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>
frahlg and others added 3 commits October 15, 2026 17:47
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>
@frahlg
frahlg merged commit f5cdb92 into master Oct 15, 2026
18 checks passed
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Oct 15, 2026
* 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>
frahlg added a commit to srcfl/ftw-web that referenced this pull request Oct 16, 2026
* 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>
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