05-subnet-probe.md
Change 5 — Subnet probe for the pairing port
DRAFT. Conforms to
_GROUND-TRUTH.md. Proposed new module:/Users/june/projects/peach-browser/src/background/desktop-pairing-probe.ts. Consumed by Change 3 (findHost).
Verdict: the EXISTING probe cannot be reused as-is
There IS an existing /24 probe, and its port is already parameterized:
// src/shared/chirp-sync-transport.ts:238
export async function probeSubnetForPeer(
subnet: string, // e.g. "192.168.1"
port: number, // ← already a parameter (used at lines 256, 261-263)
ourLongTermPrivateKey: Uint8Array,
ourLongTermPublicKey: Uint8Array,
expectedRemotePubkeyHex: string,
options?: { batch?; timeoutMs?; cooldownMs?; now? },
): Promise<SyncEndpoint | null>
So "just call it with 49142" is tempting — but it is wrong for pairing:
probeSubnetForPeerruns a full Noise XX handshake against an expected long-term static key per host (probeOne→openChirpSyncSession,chirp-sync-transport.ts:272-292, 51-156). It only returns a host that completes Noise XX withexpectedRemotePubkeyHex.- During code pairing we have no long-term pubkey yet — we only obtain
the helper's ephemeral key, and only AFTER the plaintext
PEACH_HELLO/PEACH_HELLO_ACKexchange (ground truth §2-3). The helper on port 49142 speaks the HELLO protocol, not Noise XX.openChirpSyncSessionwould send a Noise XX msg1 the pairing server never expects and fail every host. probeSubnetForPeeris also rate-limited per-pubkey (subnetProbeOnCooldown,chirp-sync-transport.ts:210-217); there is no pubkey to key the cooldown on during pairing.
Therefore Change 5 supplies a lighter, handshake-free port probe: "is there a WebSocket server answering on this port?" — nothing more. The HELLO handshake then happens against the single found host in Change 3.
Design
A plain /24 scan that returns the first IP whose ws://<ip>:<port>/
WebSocket reaches onopen within a tight timeout, then immediately closes it.
No Noise, no pubkey, no cooldown ledger. Mirrors the batching/timeout shape of
probeSubnetForPeer (batches of 16, ~500ms timeout) so worst-case scan time
stays ~comparable.
NOTE: a bare port probe is intentionally undiscriminating — it returns the FIRST host with an open WebSocket on 49142, which during an active
peach-sync pairsession is the helper. The security boundary is NOT the probe: it is (a) the 6-digit code inPEACH_HELLO(a non-matching host closes the socket) and (b) the user-confirmed SAS. The probe only needs to find a candidate; it does not need to authenticate it.
Apply-ready module — src/background/desktop-pairing-probe.ts
// Handshake-free /24 port probe for desktop pairing (Change 5).
//
// Unlike `probeSubnetForPeer` (chirp-sync-transport.ts), this does NOT run
// Noise — during code pairing we have no long-term pubkey, and the pairing
// server on port 49142 speaks the plaintext PEACH_HELLO protocol, not Noise
// XX. This probe answers only "is a WebSocket server reachable here?"; the
// 6-digit code + user-confirmed SAS are the actual trust boundary.
const DEFAULT_PROBE_TIMEOUT_MS = 500;
const DEFAULT_PROBE_BATCH = 16;
export interface PortProbeOptions {
batch?: number;
timeoutMs?: number;
/** Injection seam for tests — defaults to the real WebSocket open check. */
probeOne?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;
}
/**
* Scan `subnet` (e.g. "192.168.1") for the first host accepting a WebSocket
* connection on `port`. Returns the IP string, or null if none answered.
*/
export async function probeSubnetForPort(
subnet: string,
port: number,
options: PortProbeOptions = {},
): Promise<string | null> {
const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const probe = options.probeOne ?? wsPortOpen;
for (let start = 1; start <= 254; start += batchSize) {
const end = Math.min(start + batchSize - 1, 254);
const ips: string[] = [];
for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);
const results = await Promise.all(
ips.map(async (ip) => ((await probe(ip, port, timeoutMs)) ? ip : null)),
);
const hit = results.find((r): r is string => r !== null);
if (hit) return hit;
}
return null;
}
/** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */
function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
let ws: WebSocket;
let settled = false;
const done = (ok: boolean) => {
if (settled) return;
settled = true;
try { ws.close(); } catch { /* ignore */ }
resolve(ok);
};
try {
ws = new WebSocket(`ws://${ip}:${port}/`);
} catch {
resolve(false);
return;
}
const t = setTimeout(() => done(false), timeoutMs);
ws.onopen = () => { clearTimeout(t); done(true); };
ws.onerror = () => { clearTimeout(t); done(false); };
ws.onclose = () => { clearTimeout(t); done(false); };
});
}
Why a new module rather than extracting from chirp-sync-transport.ts
Extracting a shared scan core from probeSubnetForPeer would force the Noise
flow and the pairing flow to share a helper whose only common part is the
nested batch loop (~10 lines). The per-host predicate differs fundamentally
(full Noise XX vs bare WS open), as does the cooldown handling. A standalone
module keeps the pairing path free of the sync transport's pubkey/cooldown
machinery and avoids touching the load-bearing, already-tested sync probe.
No edit to chirp-sync-transport.ts is required.
Call sites
- Only new caller: Change 3's
productionDesktopPairingDeps().findHost(03-desktop-pairing-handler.md), which callsprobeSubnetForPort(subnet, 49142)withsubnetfromgetLocalSubnet()(src/shared/local-network.ts, already used atchirp-sync-runner.ts:69). - No existing call site changes.
Integration TODO
- Consider a hard ceiling on total scan time (the audio-chirp flow uses a 90s chirp window; a pairing probe should resolve much faster — a full /24 at 16-wide batches of 500ms is ~8s worst case, acceptable).
- The probe finds the FIRST open WS on the port. If multiple hosts answer
(unlikely on a home LAN during an active pairing), only the first is tried;
the wrong-code silent-close in Change 3 then surfaces as
invalid_code. A future enhancement could try each candidate in turn until one ACKs — out of scope for this draft.