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}6061export class SyncFrameTooLargeError extends Error {62 constructor(message = "sync frame too large (WebSocket close 1009)") {63 super(message);64 this.name = "SyncFrameTooLargeError";65 }66}6768function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {69 return new TextEncoder().encode(JSON.stringify({70 type: "PAIR_IDENTITY",71 device_id: id.deviceId,72 device_name: id.deviceName,73 device_type: id.deviceType,74 long_term_public_key_hex: id.longTermPublicKeyHex,75 signing_public_key_hex: id.signingPublicKeyHex,76 }));77}78798081828384858687export async function openSyncSession(88 endpoint: SyncEndpoint,89 ourIdentity: SyncPeerIdentity,90 expectedRemotePubkeyHex: string,91 options: { connectTimeoutMs?: number } = {},92): Promise<SyncSession> {93 const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);94 const inbox: Uint8Array[] = [];95 const readers: Array<(b: Uint8Array) => void> = [];96 let closed = false;97 let closeError: Error = new Error("WebSocket closed");9899 ws.binaryType = "arraybuffer";100 ws.onmessage = (ev: MessageEvent) => {101 const data = ev.data;102 if (data instanceof ArrayBuffer) {103 104 105 106 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {107 108 109 110 console.warn(111 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,112 );113 closeError = new SyncFrameTooLargeError(114 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,115 );116 try { ws.close(1009, "sync frame too large"); } catch { }117 return;118 }119 const bytes = new Uint8Array(data);120 const r = readers.shift();121 if (r) r(bytes);122 else inbox.push(bytes);123 }124 };125 ws.onerror = () => {126 127 128 129 console.warn("[Peach] sync WebSocket error; closing transport");130 try { ws.close(); } catch { }131 };132 ws.onclose = (event: CloseEvent) => {133 closed = true;134 if (event.code === 1009) {135 closeError = new SyncFrameTooLargeError();136 }137 while (readers.length > 0) {138 const r = readers.shift()!;139 r(new Uint8Array(0));140 }141 };142143 function send(bytes: Uint8Array): Promise<void> {144 return new Promise((resolve, reject) => {145 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {146 reject(147 new SyncFrameTooLargeError(148 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,149 ),150 );151 return;152 }153 try {154 ws.send(bytes as Uint8Array<ArrayBuffer>);155 resolve();156 } catch (err) {157 reject(err as Error);158 }159 });160 }161 function receive(): Promise<Uint8Array> {162 if (closed) return Promise.reject(closeError);163 const v = inbox.shift();164 if (v) return Promise.resolve(v);165 return new Promise<Uint8Array>((resolve, reject) => {166 readers.push((b) => {167 if (b.length === 0 && closed) reject(closeError);168 else resolve(b);169 });170 });171 }172173 let session: NoiseNKSession;174 try {175 176 177 178 session = await noiseNKInitiator(hexToPubkey(expectedRemotePubkeyHex), send, receive);179 } catch (err) {180 try { ws.close(); } catch { }181 throw err;182 }183184 try {185 await send(session.encrypt(encodePairIdentity(ourIdentity)));186 const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));187 if (theirFrame?.type !== "PAIR_IDENTITY") {188 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);189 }190 } catch (err) {191 try { ws.close(); } catch { }192 throw err;193 }194195 return {196 send(plaintext: Uint8Array) {197 const ciphertext = session.encrypt(plaintext);198 if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {199 throw new SyncFrameTooLargeError(200 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,201 );202 }203 ws.send(ciphertext as Uint8Array<ArrayBuffer>);204 },205 async receive() {206 const ct = await receive();207 return session.decrypt(ct);208 },209 async close() {210 try { ws.close(); } catch { }211 },212 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),213 };214}215216function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {217 return new Promise((resolve, reject) => {218 let ws: WebSocket;219 try {220 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);221 } catch (err) {222 reject(err as Error);223 return;224 }225 const t = setTimeout(() => {226 try { ws.close(); } catch { }227 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));228 }, timeoutMs);229 ws.onopen = () => {230 clearTimeout(t);231 resolve(ws);232 };233 ws.onerror = () => {234 clearTimeout(t);235 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));236 };237 });238}239240241242243244245246247248249250const lastProbeAt = new Map<string, number>();251252export function subnetProbeOnCooldown(253 expectedRemotePubkeyHex: string,254 now: number = Date.now(),255 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,256): boolean {257 const last = lastProbeAt.get(expectedRemotePubkeyHex);258 return last !== undefined && now - last < cooldownMs;259}260261262export function _resetSubnetProbeCooldown(): void {263 lastProbeAt.clear();264}265266267268269270271272273274275276277278279280export async function probeSubnetForPeer(281 subnet: string, 282 port: number,283 ourIdentity: SyncPeerIdentity,284 expectedRemotePubkeyHex: string,285 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},286): Promise<SyncEndpoint | null> {287 const now = options.now ?? Date.now();288 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;289 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {290 return null;291 }292 lastProbeAt.set(expectedRemotePubkeyHex, now);293 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;294 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;295 for (let start = 1; start <= 254; start += batchSize) {296 const end = Math.min(start + batchSize - 1, 254);297 const ips: string[] = [];298 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);299300 const results = await Promise.all(ips.map((ip) => probeOne(301 { ip, port },302 ourIdentity,303 expectedRemotePubkeyHex,304 timeoutMs,305 )));306 const hit = results.find((r): r is SyncEndpoint => r !== null);307 if (hit) return hit;308 }309 return null;310}311312async function probeOne(313 endpoint: SyncEndpoint,314 ourIdentity: SyncPeerIdentity,315 expectedRemotePubkeyHex: string,316 timeoutMs: number,317): Promise<SyncEndpoint | null> {318 try {319 const session = await openSyncSession(320 endpoint,321 ourIdentity,322 expectedRemotePubkeyHex,323 { connectTimeoutMs: timeoutMs },324 );325 await session.close();326 return endpoint;327 } catch {328 return null;329 }330}