Skip to content

iPhone: the box session goes through FTW Cloud, the relay as fallback (one door, step 2) - #67

Merged
frahlg merged 5 commits into
mainfrom
claude/ios-cloud-tunnel
Oct 15, 2026
Merged

frahlg merged 5 commits into
mainfrom
claude/ios-cloud-tunnel

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

One-door plan, step 2: the iPhone's box session goes through FTW Cloud, with the relay as fallback for one release.

Paired PRs

QRNamesHome stays off. This PR, #89 and the Android parser here are the gate for turning it on.

What changes

The tunnel carrier (Carrier/TunnelCarrier.swift). Runs the box session (ftw.session.v1, Noise IK to the box key, byte for byte) as tun {sid, d} inside the existing /v1/app session. It sits under NoiseCarrier(inner:) where RelayCarrier sat. Session is untouched.

  • The box session's handshake message 1 waits for the app session's message 1 and rides in it as now.tun, so the box session is up one round trip after TLS. If the app session is already past message 1, it goes the moment message 2 is in.
  • Each handshake draws a new random sid: Carrier.beginSession(), which NoiseCarrier calls before message 1 and every other carrier ignores. A late frame or end of an older session is dropped.
  • An empty d ends the session. It may mean the box dropped it, another session of this device took the slot, or the box does not hold it; the phone cannot tell which. A new session starts after a backoff (full jitter, 0.5 s to 60 s) that starts over only after a session carried for 30 s, so a replaced session never restarts in a loop.
  • Session's hello is the first transport frame and goes the moment the handshake completes; the box takes the device's slot on it (the change coming to #1635). A frame the box drops on a full queue costs one frame: the transport accepts gaps.
  • wake() dials the app session afresh, as the relay carrier does. Closing the carrier ends the session at the box and leaves the app session up for the readable copy.
  • Admission is the device key in the roster. While the phone holds a pairing code it also sends now.pair, SHA-256(ftw.cloud.pair.v1\0 ‖ home ‖ code): that admits a new phone from a v3 code, or one paired again after it was removed. FTW Cloud ignores it from a phone the roster lists. The code itself rides only inside the box session's message 1, encrypted to the box.
  • A refusal on the first service key keeps the box session's message 1 for the retry on the second.

The fallback (Carrier/FallbackCarrier.swift, for one release). Tunnel first. Until the box has answered a transport frame through it (its handshake answer alone does not count), the relay takes over at once on a refusal (4403), an unreachable service, or a session ended before that answer, and after 5 s without one (an older box lists the phone without tun, and FTW Cloud then says nothing). A tunnel that carried and drops, or that a wake dials afresh, gets the same 5 s to carry again. One carrier at a time: the tunnel and the relay share the device's one slot at the box, so the tunnel closes and ends its session at the box before the relay dials, and the relay is never built while the tunnel carries. The next carrier, built when the app comes to the front, tries the tunnel again. The log says when the relay took over and why (energy.ftw.app, category carrier).

When the phone uses the tunnel (SiteModel.tunnelCloud). It needs a home id and a box that takes tun: the box said cloud.tun at its last hello, or it has never spoken to this phone and a v3 code named the home. Otherwise the relay, as before, with no wait.

The home id. From a v3 code at pairing, or from hello_ok.cloud at the first session, kept in StoredSite.cloudHome. The FTW Cloud session now starts from the kept id too, not only from the box's word at hello.

Honest freshness. A new carrier state tunnel (registry). The band says "Live, encrypted via FTW Cloud" / "Live, krypterat via FTW Cloud", and the node footer "ENCRYPTED VIA FTW CLOUD". The relay keeps its own words, so a fallback is visible. cloud stays the readable copy.

The readable copy. A box from #1635 names its home in hello_ok.cloud with the copy off too, with empty tables. SiteModel.cloudOn now means the copy is on (non-empty tables, as the protocol text says). Without this, CloudConsent.ensure would read a linked box as "already on" and never turn the copy on for an owner who agreed. Push registration, and the notification prompt with it, now waits for the copy to be on. A hello without cloud no longer closes an FTW Cloud session that carries the box session.

QR v3 in Enrollment.swift and Android's Enrollment.kt, v2 still read. Both take only a home id FTW Cloud could have cut, else E_QR_HOME.

Files outside this PR's own area. One line each in WatchShare.swift (the watch gets tunnel as the carrier) and WatchGlance.swift (a tunnel reading is lit), U3's area; neither touches U3's open lines. A watch build older than this phone build cannot decode the carrier tunnel; the watch app ships in the same bundle. FTWApp.swift gets a debug-only -FTWRelayURL, to prove the app with the relay pointed nowhere.

Proof

Unit tests (FTWKit 426 + FTWShared 227, all green; Kotlin :shared:jvmTest green; the app, watch and widgets build for the iOS Simulator):

  • CloudWireTests: Go's vectors from #1635 replay byte for byte: the tun frames in an app session, now.pair and now.pair + now.tun message 1, the pairing hash and the device id.
  • TunnelCarrierTests against a fake service and a fake box that runs the responder handshake: message 1 rides message 1 and the session is up 30 ms after a 10 ms socket open (three legs); message 1 waits for message 2 when the app session is already dialling; each handshake gets a new sid; an end from the box starts a new session; a box that ends every session at the first transport frame gets eight sessions in a minute, not two hundred, and a session that carried 30 s restarts at once; a late frame of an older session is dropped; a wake dials afresh; close ends the session at the box and leaves the app session; a retry on the other service key carries message 1 again; a new phone pairs through the service with only the hash outside Noise.
  • FallbackCarrierTests: the relay stays undialled while the tunnel carries; refused, unreachable, ended before any answer, ended after only the handshake answer, and silent each move to the relay; a carrying tunnel that drops briefly stays; one that stays down moves after 5 s, and so does one that a wake dials into silence.
  • OneDoorShellTests, the whole AppModel against a simulated box behind FTW Cloud: a v3 code pairs and streams through the tunnel with the relay unreachable, and comes back from the background through it; a box without cloud.tun is reached through the relay with no wait; a refusing FTW Cloud falls back to the relay.

End to end (LiveTunnelTests, off unless FTW_LIVE_TUNNEL_DIR is set): ftwcloudd built from srcfl/ftw-cloud#15 (462813d); a box from srcfl/ftw#1639 on #1635 in process, with its real app uplink, cloud link, tunnel table and command handler, and its relay pointed at a closed port; this app's AppModel, PairModel and FTWKit stack on the Mac with its relay at wss://127.0.0.1:9. The phone scanned the v3 link, paired through FTW Cloud, streamed, set the mode 50 times and came back from the background 5 times. The relay was dialled 0 times; the box set the mode 50 times.

Path Scan to streaming Tap to cmd.ack median / p95 / max Tap to cmd.result median / p95 Foreground to streaming
Loopback 39–52 ms 1.3 / 2.0–2.8 / 2.5–3.4 ms 1.5 / 2.3–3.0 ms 18–20 ms, once 69 ms
50 ms round trip phone to cloud, 20 ms cloud to box (TCP delay proxy) 311 ms 75.8 / 96.5 / 241 ms 76.2 / 101 ms 357, 374, 600, 349, 340 ms

With delay, tap to ack is one round trip to the cloud plus one to the box (70 ms) and 6 ms of work. The plan's budget is under 300 ms median in Sweden.

Not yet: the app in the simulator against this stack, and a device on Wi-Fi and LTE. Two other sessions hold the simulators; this PR gets a comment when the simulator run is done.

Next release: remove the relay path

Once Core with cloud.tun (#1635, #1639) is out and FTW Cloud runs #15, the next iPhone release deletes:

  • RelayCarrier.swift, Rendezvous.swift, FallbackCarrier.swift, Origin.relayURL, AppModel.relayURL and -FTWRelayURL.
  • Trial decryption in NoiseCarrier: foreignFrameErrors, and taking only frames of message 2's length while a handshake runs. The tunnel delivers only its own session's frames.
  • The relay's close codes (4400, 4409, 4410, 4429), CarrierStatus.isBusy and the "FTW is busy" band.
  • StoredSite.rendezvousSecret as a way in: a home with no home id then asks to be paired again. Phones learn the id from hello_ok.cloud during this release.
  • The relay tests: FakeRelay, the relay cases in RemoteTests, the relay path in LiveBoxTests and LiveCloudTests.

Android keeps Relay.kt and Rendezvous.kt until step 4, and the registry keeps relay until the web app moves in step 5.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 15, 2026 17:21
…lback

TunnelCarrier carries ftw.session.v1 inside the /v1/app session; its
message 1 rides now.tun. FallbackCarrier moves to the relay when the
tunnel does not carry. QR v3 names the home; hello_ok.cloud fills it in.
The carrier is reported as tunnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the whole app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eachable; Android reads QR v3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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-15T15:50:10.832376Z 4956949 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: 4956949855

ℹ️ 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 on lines +94 to +95
if !retryable || !carrying {
toRelay()

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 Preserve the recovery window across tunnel retries

When a previously carrying tunnel drops, this method clears carrying and starts the five-second recovery timer. If the cloud connection's first retry also closes—for example during a brief network flap or service restart—the next callback hits !carrying here and switches immediately to the relay even though the recovery window has not elapsed. Because fallback is one-way for the rest of the foreground session, preserve separate state for “has ever carried” or an active recovery window and defer takeover until that timer expires.

Useful? React with 👍 / 👎.

… no restart loop on an end

The box takes a device's slot on the phone's first transport frame, so
its handshake answer alone does not show the session is this phone's.
An end cannot say whether the session was replaced or unknown, so the
backoff after one starts over only after a session carried for 30 s.
From review: message 1 survives a retry on the other service key, a
wake gives the tunnel the fallback's wait again, the kept home id wins
over a cached identity, the pairing proof rides while a code is held,
and the relay taking over is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit c26af60 into main Oct 15, 2026
1 check passed
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