Skip to content

feat: carry each phone's session to its box, and survive a restart - #15

Merged
frahlg merged 4 commits into
mainfrom
feat/tunnel
Oct 15, 2026
Merged

frahlg merged 4 commits into
mainfrom
feat/tunnel

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Step 1 of the one-door plan, the ftw-cloud half. Paired with srcfl/ftw#1635: go.mod pins that branch's go/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

  • A phone's tun {sid, d} on /v1/app goes to its box as tun {dev, sid, d}. dev comes from the roster entry the service's own handshake matched; a dev a phone adds is ignored. The box's answers go back to that device's session. d is never read.
  • Routed per device on queues of their own (store.Session.Tun), never held as a class, so a reading never waits behind the tunnel or the other way round.
  • Only a box that lists tun in hello.caps gets any, since an older box closes on a type it does not know.
  • An empty d ends 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.tun carries 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

  • A roster device needs tun or a readable copy to be admitted.
  • While the box shows a QR code its roster lists the code's hash. A new phone proves it in now.pair and is admitted once, as a device that may only tunnel. When the roster lists it, its session takes that entry and stays open.

Limits

  • Per home and direction: 240 tun in 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.
  • tun does not count against the box's or the phone's message rate. A phone sending twice the home's allowance closes.
  • Frames up to MaxTunFrame. /v1/app lets in Origin: https://app.ftw.energy, no other.

The readable copy

  • Readings are held only while the roster's grant serves. A withdrawn grant (id, no purposes) deletes the copy and the extension tokens and gets a signed purged {what: "copy"}, again for each such roster. The home, its roster and its tunnel stay.

Before commands move here (review §3.5)

  • Signed rosters are read back from the registry at start and checked with the home's key: phones are admitted before their box reconnects (uplink.Restore).
  • box.unreachable waits survive a restart (away table, 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.
  • A connection becomes the home's only after hello_ok has gone, so watch or tun never precede it. TestNothingReachesABoxBeforeHelloOK fails with the old order.

Proof

  • internal/server/tunnel_e2e_test.go, the whole service behind TLS 1.3 as ftwcloudd wires it, with a Go box that answers the phone's real Noise session:
    • TestACommandThroughTheServiceGetsTheBoxsAnswer: a phone in the roster sends cmd and gets cmd.ack and cmd.result; the copy is off and the snapshot is empty. 20 runs: median 0.54 ms, max 0.88 ms.
    • TestTheBoxSessionRidesInMessage1
    • TestAPhoneNotInTheRosterIsRefusedBeforeAnythingIsForwarded: 4403 before message 2, and the box receives nothing.
    • TestAForgedDevIsIgnored
    • TestTheCopyStaysOffWhileTheTunnelWorks
    • TestAPhonePairsThroughTheService, TestEachEndHearsTheOtherGo, TestOnlyTheWebAppsOriginIsLetIn
  • uplink/tunnel_test.go, push/resume_test.go, fuzz targets for tun from a phone and a box.
  • Measured with feat(cloud): carry the phones' sessions through FTW Cloud, linked from pairing ftw#1635's real cloudlink, tunnel and handler against this branch's ftwcloudd binary on loopback, relay unreachable: cmd to cmd.ack median 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)

  • B A box that dials again ends the tunnels its older uplink carried; phones hear it even while the old socket is open (TestABoxThatRedialsEndsThePhonesOldTunnel).
  • A A box at twice the home's tun limits is closed; under that the service drops and counts (TestABoxFloodingTunIsClosed). The limits are now cloudwire.TunMsgBurst and friends, shared with the box, which since feat(cloud): carry the phones' sessions through FTW Cloud, linked from pairing ftw#1635 0970e7b3 paces 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).
  • D Readings leave memory whenever a roster does not serve.
  • E A restart does not restore pairing codes. F A key the roster lists never enters by pairing. M The hash is spent only when the session opens; a full home answers busy and keeps it.
  • G A roster that takes a device's tunnel away ends its session (4409).
  • H The box hears the end of a phone's tunnel however the app session ends. N A d over TunDataMax closes the phone with 4400.
  • I Push hooks carry the uplink's connection number; the late end of an older connection never starts box.unreachable for a box that is back.
  • K Suspensions are read from memory (written through to SQLite), so admission under a home's lock never waits for the file.
  • L Dropped tun is logged once a minute as a service-wide count; it names no home.
  • C CLAUDE.md and README corrected (unsigned 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.caps and grantId describe the connection's start; the service takes what it may hold from the newest roster's grant. TestTheCopyTurnsOnAndOffMidConnection runs 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 parse v3, the QR that names the home. Pairing through this service with the relay blocked needs that. The pending roster entry and now.pair admission land here now and are tested.

Not in this PR

  • POST /v1/app/x for the watch (step 3).
  • A drain or standby for deploys, closing outbound traffic and SSM session logs (ADR 0008); the per-part line budgets. The service is now further over its 3,000-line budget.
  • Nothing is deployed.

Supersedes the direction of #14, which ports the relay's room protocol; Fredrik asked for that to pause.

🤖 Generated with Claude Code

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>
@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:13.463005Z b2ac73e 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.

@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: 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".

Comment thread internal/store/store.go Outdated
Comment on lines +518 to +519
h.roster, h.rosterAt, h.lastSeen = r, at, at
h.setPendingLocked(r, at)

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

Comment thread internal/store/store.go
Comment on lines 253 to +256
if x.h.conn == conn {
x.h.online = false
x.h.lastSeen = x.s.now()
x.h.tunGoneLocked()

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

frahlg added a commit to srcfl/ftw that referenced this pull request Oct 15, 2026
… 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>
frahlg added a commit to srcfl/ftw that referenced this pull request Oct 15, 2026
…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>
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