12345678910111213141516171819202122232425262728import { noiseNKInitiator, type NoiseNKSession } from "./noise-js";29import { SYNC_LIMITS } from "./sync-limits";30import { hexToPubkey } from "./sync-identity";31import type { DeviceType } from "./sync-types";3233const DEFAULT_PROBE_TIMEOUT_MS = 500;34const DEFAULT_PROBE_BATCH = 16;35const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;3637export const SUBNET_PROBE_COOLDOWN_MS = 60_000;3839export interface SyncSession {40 send(plaintext: Uint8Array): void;41 receive(): Promise<Uint8Array>;42 close(): Promise<void>;43 remoteStaticPublicKey: Uint8Array;44}4546export interface SyncEndpoint {47 ip: string;48 port: number;49}50515253export interface SyncPeerIdentity {54 deviceId: string;55 deviceName: string;56 deviceType: DeviceType;57 longTermPublicKeyHex: string;58 signingPublicKeyHex?: string;59}6061function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {62 return new TextEncoder().encode(JSON.stringify({63 type: "PAIR_IDENTITY",64 device_id: id.deviceId,65 device_name: id.deviceName,66 device_type: id.deviceType,67 long_term_public_key_hex: id.longTermPublicKeyHex,68 signing_public_key_hex: id.signingPublicKeyHex,69 }));70}71727374757677787980export async function openSyncSession(81 endpoint: SyncEndpoint,82 ourIdentity: SyncPeerIdentity,83 expectedRemotePubkeyHex: string,84 options: { connectTimeoutMs?: number } = {},85): Promise<SyncSession> {86 const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);87 const inbox: Uint8Array[] = [];88 const readers: Array<(b: Uint8Array) => void> = [];89 let closed = false;9091 ws.binaryType = "arraybuffer";92 ws.onmessage = (ev: MessageEvent) => {93 const data = ev.data;94 if (data instanceof ArrayBuffer) {95 96 97 98 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {99 100 101 102 console.warn(103 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,104 );105 try { ws.close(); } catch { }106 return;107 }108 const bytes = new Uint8Array(data);109 const r = readers.shift();110 if (r) r(bytes);111 else inbox.push(bytes);112 }113 };114 ws.onerror = () => {115 116 117 118 console.warn("[Peach] sync WebSocket error; closing transport");119 try { ws.close(); } catch { }120 };121 ws.onclose = () => {122 closed = true;123 while (readers.length > 0) {124 const r = readers.shift()!;125 r(new Uint8Array(0));126 }127 };128129 function send(bytes: Uint8Array): Promise<void> {130 return new Promise((resolve, reject) => {131 try {132 ws.send(bytes as Uint8Array<ArrayBuffer>);133 resolve();134 } catch (err) {135 reject(err as Error);136 }137 });138 }139 function receive(): Promise<Uint8Array> {140 if (closed) return Promise.reject(new Error("WebSocket closed before frame"));141 const v = inbox.shift();142 if (v) return Promise.resolve(v);143 return new Promise<Uint8Array>((resolve, reject) => {144 readers.push((b) => {145 if (b.length === 0 && closed) reject(new Error("WebSocket closed"));146 else resolve(b);147 });148 });149 }150151 let session: NoiseNKSession;152 try {153 154 155 156 session = await noiseNKInitiator(hexToPubkey(expectedRemotePubkeyHex), send, receive);157 } catch (err) {158 try { ws.close(); } catch { }159 throw err;160 }161162 try {163 await send(session.encrypt(encodePairIdentity(ourIdentity)));164 const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));165 if (theirFrame?.type !== "PAIR_IDENTITY") {166 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);167 }168 } catch (err) {169 try { ws.close(); } catch { }170 throw err;171 }172173 return {174 send(plaintext: Uint8Array) {175 const ciphertext = session.encrypt(plaintext);176 ws.send(ciphertext as Uint8Array<ArrayBuffer>);177 },178 async receive() {179 const ct = await receive();180 return session.decrypt(ct);181 },182 async close() {183 try { ws.close(); } catch { }184 },185 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),186 };187}188189function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {190 return new Promise((resolve, reject) => {191 let ws: WebSocket;192 try {193 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);194 } catch (err) {195 reject(err as Error);196 return;197 }198 const t = setTimeout(() => {199 try { ws.close(); } catch { }200 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));201 }, timeoutMs);202 ws.onopen = () => {203 clearTimeout(t);204 resolve(ws);205 };206 ws.onerror = () => {207 clearTimeout(t);208 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));209 };210 });211}212213214215216217218219220221222223const lastProbeAt = new Map<string, number>();224225export function subnetProbeOnCooldown(226 expectedRemotePubkeyHex: string,227 now: number = Date.now(),228 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,229): boolean {230 const last = lastProbeAt.get(expectedRemotePubkeyHex);231 return last !== undefined && now - last < cooldownMs;232}233234235export function _resetSubnetProbeCooldown(): void {236 lastProbeAt.clear();237}238239240241242243244245246247248249250251252253export async function probeSubnetForPeer(254 subnet: string, 255 port: number,256 ourIdentity: SyncPeerIdentity,257 expectedRemotePubkeyHex: string,258 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},259): Promise<SyncEndpoint | null> {260 const now = options.now ?? Date.now();261 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;262 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {263 return null;264 }265 lastProbeAt.set(expectedRemotePubkeyHex, now);266 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;267 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;268 for (let start = 1; start <= 254; start += batchSize) {269 const end = Math.min(start + batchSize - 1, 254);270 const ips: string[] = [];271 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);272273 const results = await Promise.all(ips.map((ip) => probeOne(274 { ip, port },275 ourIdentity,276 expectedRemotePubkeyHex,277 timeoutMs,278 )));279 const hit = results.find((r): r is SyncEndpoint => r !== null);280 if (hit) return hit;281 }282 return null;283}284285async function probeOne(286 endpoint: SyncEndpoint,287 ourIdentity: SyncPeerIdentity,288 expectedRemotePubkeyHex: string,289 timeoutMs: number,290): Promise<SyncEndpoint | null> {291 try {292 const session = await openSyncSession(293 endpoint,294 ourIdentity,295 expectedRemotePubkeyHex,296 { connectTimeoutMs: timeoutMs },297 );298 await session.close();299 return endpoint;300 } catch {301 return null;302 }303}