# Scoping: a direct connection to a Start9 host via `bindPortRange` **Status:** **REJECTED by measurement. P0 was built and run on 2026-07-29 and the answer is NO** - see [P0 RESULT](#p0-result-2026-07-29-the-forward-does-not-fix-the-punch) below. P1-P3 were not started, and Start9 releases are TABLED (Tim, 2026-07-29). The rest of this document is kept as the reasoning that led there, not as a plan. **Tier:** T2 (packaging + a small additive host option; no new trust boundary - see Security). **Question:** what would it actually take to stop every Start9 user's music crossing our relay? ## The problem, measured PearTune runs on StartOS 0.4.0 and works. It works entirely through the blind relay. | Condition | Relay bytes, playback minute | Idle baseline | |---|---|---| | Pixel on home WiFi | +5,409,600 | ~60 KB/min | | Pixel on cellular | +2,126,169 (+346,747 tail) | ~40 KB/min | The punch fails in **both** directions, so this is not the same-LAN hairpin the 2026-07-18 note described - that note had cellular punching reliably. Every byte between any phone and a StartOS 0.4 host crosses the droplet, at ~5 MB/track, on the $4 / 500 GB tier whose binding constraint is already bandwidth. The cost scales with **listening**, not with user count. Why 0.4 is worse than Umbrel: Umbrel runs the host with `network_mode: host`, so there is exactly one NAT between the host and the internet - the home router - which HyperDHT punches through routinely. On StartOS every package sits in an unprivileged LXC container on `lxcbr0` (`10.0.3.0/24`, `lxc.net.0.type = veth`), so there are **two** NATs in series. There is no host-networking option anywhere in `@start9labs/start-sdk` 2.0.9. ## The candidate fix `MultiHost.bindPortRange` / `Effects.bindRange` is the only raw-UDP door in the 0.4 SDK: *"a contiguous range of **UDP+TCP** ports"*, documented for coturn / RTP / SIP, with the external range mapped onto the internal by offset and **one nft rule per chain** covering the range. The single-port `bindPort` is protocol-typed (`http`/`https`/`ws`/`wss`/`ssh`/`dns` with `addSsl`/`secure`), i.e. TCP and TLS only, so it cannot carry the DHT. The idea: pin the host's DHT UDP port, forward it 1:1 on the box, and let the phone reach a predictable port - collapsing the inner NAT so the box behaves like the host-networked Umbrel case. ## What is already verified - **The DHT port is cleanly pinnable.** `new HyperDHT({ port: 49999 })` binds exactly 49999; `new HyperDHT()` binds a random port per process (36600 / 42742 / 59270 over three runs - the `opts.port || 49737` default at `hyperdht/index.js:27` is a preference that does not survive). So the host side is a small additive change. - **The published image can be reused as-is.** The 0.4 SDK takes `images..source.dockerTag`, so `ghcr.io/peerloomllc/peartune-host:0.2.36` can be pinned directly - **no runtime rewrite**, which removes the largest thing anyone would fear here. - **The box supports it.** `bindPortRange` landed in SDK 2.0.0 / StartOS 0.4.0-beta.10; `returned-feline.local` is on released 0.4.0. - **Constraints:** `numberOfPorts` is 2-500 (a single port is pushed at `bindPort`), the range is allocated atomically, and a partial collision is a hard error. So we request 2 and use the first. ## THE RISK THAT DECIDES THIS, and it is not the packaging **A port forward may fix inbound without fixing the punch.** Holepunching needs the container's **outbound** UDP to leave from the *same* external port that inbound arrives on. `bindPortRange` is documented as installing forwarding rules - i.e. DNAT for traffic coming *in*. If the container's outbound packets are still SNATed to an arbitrary source port by `lxcbr0`, then the mapping stays endpoint-dependent, the punch keeps failing, and the relay keeps carrying everything. The forward would only help a peer that dials the port explicitly, which is not how HyperDHT connects. Nothing in the SDK docs says whether the rule is symmetric. **This is unknown, it is the whole question, and it is cheap to answer** - which is why the plan below spends a probe on it before anything else. ## P0 RESULT (2026-07-29): the forward does not fix the punch Built as a genuine **native** StartOS 0.4 package (SDK 2.0.9) - not a conversion - and installed on `returned-feline.local` alongside the converted one, which was **stopped first** so it could not confound the measurement. Package kept at `start9/probe-04/`. **Every precondition was met**, which is what makes the negative trustworthy: - The port is pinned: `49737` bound inside the container (`10.0.3.213`), read from `/proc/net/udp`. - StartOS installed **real forwards**, including on the LAN interface - something the converted package never had: `ip daddr 192.168.50.253 ... th dport 49737-49738 dnat to 10.0.3.213`. - Outbound is the standard port-preserving masquerade: `ip saddr 10.0.3.0/24 ... masquerade`. **And the audio still relayed:** | Window | Relay bytes | |---|---| | idle | +95K / +62K / +29K / +33K / +33K per minute | | **minute play was tapped** | **+5,395,189** | Within 0.3% of the +5,409,600 measured before the probe existed, and both ~= the 5,004,158-byte demo track plus overhead. Control: the probe served 90+ seconds of continuous playback (`resume:set` 25.6s -> 97.9s). *Reading the timestamps:* the spike lands in the sample just **before** `resume:set` starts climbing. That is expected, not a mismatch - PearTune fetches a whole track in one request and plays from the buffer, so bytes move when you tap play, not while the counter advances. Pairing alone was ~141 KB, so 5.4 MB cannot be pairing. **Why:** inbound reachability was never the blocker. Holepunching needs the container's **outbound** UDP to leave from the same external port that inbound arrives on, and `lxcbr0` does not give us that. A forward fixes only the half that was not broken - precisely the risk named below as the one that decides this. **Consequence:** P1-P3 are not worth doing. Start9 on 0.4 is relayed, and the options are the two in Alternatives: ship relayed, or do not ship. Tim's call on 2026-07-29 was **do not ship for now**. Revisit only if StartOS gains host networking or a symmetric forward, or if the relay's economics change. **One correction to the plan below:** it puts P0 before P1, which is wrong. The probe cannot pin a port the host does not support, so the host option (`PEARTUNE_DHT_PORT`, shipped in #255) had to come first. ## Plan ### P0 - the probe (do this first, alone) A throwaway 0.4 package whose only job is to answer the SNAT question. Scaffolded already via `start-cli s9pk init-package`; it reuses the GHCR image by `dockerTag`, sets a fixed `PEARTUNE_DHT_PORT`, declares `bindPortRange({ internalStartPort: N, externalStartPort: N, numberOfPorts: 2 })`, and exports it with `sdk.createRangeInterface`. **Acceptance:** pair the Pixel and play a track, on home WiFi *and* on cellular, while watching `relay:stats`. Success is relay bytes staying at the ~40-60 KB/min idle baseline through a full playback minute. Anything resembling the +2.1 MB / +5.4 MB spikes above means the punch is still failing and **the whole idea is dead** - at which point the honest options are to ship Start9 relayed, or not ship it. Cost: small. The scaffold exists, the image is reused, and the acceptance test is one already run twice today. **Measure the byte counters only after the minute containing playback has closed.** A reading taken mid-window looked flat today and nearly produced the wrong verdict. ### P1 - the host option (only if P0 passes) `PEARTUNE_DHT_PORT` -> `new HyperDHT({ port })`, defaulting to today's behaviour when unset. ~10 lines plus a test. Additive, and useful beyond Start9: any deployment that wants a predictable port for a router forward can use it. ### P2 - the native 0.4 package (only if P0 passes) The conversion route cannot do this: a converted v1 s9pk carries a 0.3.5 manifest and cannot call SDK 2.x effects at all. A native package is therefore **mandatory**, not a preference. It also fixes a defect found today independently: the converted package has **no LAN or Tor address** in 0.4's address model (`package host peartune address list` is empty for every host id), so its dashboard is unreachable through StartOS on the LAN. A native package binds 8741 properly in `setupInterfaces()` and gets that back. Scope: port the four 0.3.5 procedures (config / health / migrations / properties) into the SDK's model, mount `/data`, health check, backup, and a migration for anyone on the 0.3.5 package. Bounded by the fact that the runtime image is unchanged. ### P3 - registry publish (unchanged) Already scoped in `start9/README.md`: v2 s9pk, mandatory `commitment`, non-empty `signatures`, a real base64 icon, and a **merge-aware** generator so one write does not delist PearCal and PearCircle from the shared 0.4 document. ## Security No new trust boundary. The forwarded port carries the same Noise-authenticated, firewall-gated HyperDHT traffic the host already exposes on Umbrel with `network_mode: host` and on every desktop install. The grant store stays host-local, revocation is unchanged, and nothing new is listening that was not listening before - it is reachable on a predictable port instead of an unpredictable one. That is why this is T2 and not T3, and it is the opposite of the `worklet/shim.js` LAN-bind idea (proposal 2026-07-26 casting), which *would* have created an unauthenticated endpoint. ## Alternatives - **Ship Start9 relayed.** Works today. Costs PeerLoom bandwidth that grows with engagement, which is the wrong direction for a cost to grow. Defensible only while Start9 users are few. - **Do not ship Start9.** Its registry has no music server left on a stock box - the 0.4.0 upgrade removed Jellyfin - so the audience was always small. Bundling the demo tracks (PR #253) is what makes a Start9 install useful at all. - **Patch hyperdht** to announce an overridden local address. Investigated and rejected the same day: a core-dependency patch carried forever, and experiments B and D showed the local-address mismatch is not what breaks the connection anyway.