@sksoftofficial/ocduet 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.js ADDED
@@ -0,0 +1,747 @@
1
+ // ocduet daemon — the background bridge service.
2
+ // Owns the LAN WebSocket server, the web client, the pairing token and the
3
+ // machine-wide state file. opencode instances attach over an internal socket
4
+ // (see src/plugin/ocduet-server.js) and stream events / execute RPCs.
5
+ // Run with: node daemon.js (managed by `ocduet start|stop|restart|status`)
6
+ import http from "node:http";
7
+ import https from "node:https";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import crypto from "node:crypto";
12
+ import { spawnSync } from "node:child_process";
13
+ import { WebSocketServer } from "ws";
14
+ import {
15
+ dataDir, tokenPath, daemonStatePath, daemonPidPath, daemonLogPath, daemonWebDir,
16
+ } from "./paths.js";
17
+ import {
18
+ daemonPub, daemonWords, signDaemon, verifySig, phoneByPub, addPhone, ephX25519, ecdh,
19
+ hsInfo, deriveKeys, frameEncrypt, frameDecrypt, hs1Msg, hs2Msg, pairMsg, PD_TAG, DP_TAG,
20
+ loadKeys,
21
+ } from "./e2ee.js";
22
+ import { startRelayClient, relayStatus, readRelayConfig } from "./relay-client.js";
23
+
24
+ const VERSION = "0.2.1";
25
+ const HOST = process.env.OCDUET_HOST || "0.0.0.0";
26
+ const DATA_DIR = dataDir();
27
+ const PORT_FILE = path.join(DATA_DIR, "port");
28
+ const MAIN_PORT_FILE = path.join(DATA_DIR, "port.main");
29
+ function lastBoundPort() {
30
+ try {
31
+ const p = parseInt(fs.readFileSync(PORT_FILE, "utf8").trim(), 10);
32
+ return p > 0 ? p : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function lastMainPort() {
38
+ try {
39
+ const s = JSON.parse(fs.readFileSync(daemonStatePath(), "utf8"));
40
+ if (s?.port > 0) return s.port;
41
+ } catch {}
42
+ try {
43
+ const p = parseInt(fs.readFileSync(MAIN_PORT_FILE, "utf8").trim(), 10);
44
+ return p > 0 ? p : null;
45
+ } catch {}
46
+ return lastBoundPort(); // legacy: pre-dual-server port file held the main port
47
+ }
48
+ const BASE_PORT = parseInt(process.env.OCDUET_PORT || "0", 10) || lastMainPort() || 4098;
49
+ const STARTED_AT = Date.now();
50
+ const NETWORK_POLL_MS = 2000;
51
+ let advertisedIp = null;
52
+
53
+ const BAD_NIC = /^(lo|virbr|docker|veth|br-|zt|tailscale|tun|tap|utun|vmnet|wg|ppp|ipoib|wwan|rmnet)/i;
54
+
55
+ function log(...args) {
56
+ try {
57
+ fs.appendFileSync(daemonLogPath(), `${new Date().toISOString()} ${args.join(" ")}\n`, { mode: 0o600 });
58
+ } catch {}
59
+ }
60
+
61
+ function lanIp() {
62
+ const candidates = [];
63
+ for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
64
+ if (BAD_NIC.test(name)) continue;
65
+ for (const a of addrs || []) {
66
+ if (a.family !== "IPv4" || a.internal) continue;
67
+ if (/^192\.168\./.test(a.address)) candidates.push({ ip: a.address, rank: 0 });
68
+ else if (/^10\./.test(a.address)) candidates.push({ ip: a.address, rank: 1 });
69
+ else if (/^172\.(1[6-9]|2\d|3[01])\./.test(a.address)) candidates.push({ ip: a.address, rank: 2 });
70
+ }
71
+ }
72
+ candidates.sort((a, b) => a.rank - b.rank);
73
+ return candidates[0]?.ip ?? null;
74
+ }
75
+
76
+ function readToken() {
77
+ try {
78
+ return fs.readFileSync(tokenPath(), "utf8").trim() || null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function tokenOk(provided) {
85
+ if (!provided) return false;
86
+ const real = readToken();
87
+ if (!real) return false;
88
+ const a = Buffer.from(String(provided));
89
+ const b = Buffer.from(real);
90
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
91
+ }
92
+
93
+ function ensureToken() {
94
+ if (readToken()) return;
95
+ fs.mkdirSync(DATA_DIR, { recursive: true });
96
+ const t = crypto.randomBytes(32).toString("base64url");
97
+ fs.writeFileSync(tokenPath(), t + "\n", { mode: 0o600 });
98
+ try { fs.chmodSync(tokenPath(), 0o600); } catch {}
99
+ }
100
+
101
+ // ---------- state ----------
102
+ const phones = new Set(); // browser sockets
103
+ const instances = new Map(); // instanceId -> { ws, info, sessions }
104
+
105
+ // last-seen sessions per instance — survives instance shutdowns so the phone
106
+ // keeps listing sessions of opencode processes that are not running
107
+ const SESSIONS_CACHE_FILE = path.join(DATA_DIR, "sessions-cache.json");
108
+ const sessionCache = new Map();
109
+ try {
110
+ const raw = JSON.parse(fs.readFileSync(SESSIONS_CACHE_FILE, "utf8"));
111
+ for (const [id, e] of Object.entries(raw || {})) {
112
+ if (e?.info?.id) sessionCache.set(id, e);
113
+ }
114
+ } catch {}
115
+ let cacheStr = "";
116
+ function persistSessionCache() {
117
+ const entries = [...sessionCache.entries()]
118
+ .sort((a, b) => (b[1].at || 0) - (a[1].at || 0))
119
+ .slice(0, 12);
120
+ sessionCache.clear();
121
+ for (const [id, e] of entries) sessionCache.set(id, e);
122
+ const str = JSON.stringify(Object.fromEntries(entries));
123
+ if (str === cacheStr) return;
124
+ cacheStr = str;
125
+ try {
126
+ // session titles/directories live here — same privacy as the token
127
+ fs.writeFileSync(SESSIONS_CACHE_FILE, str, { mode: 0o600 });
128
+ try { fs.chmodSync(SESSIONS_CACHE_FILE, 0o600); } catch {}
129
+ } catch {}
130
+ }
131
+ function pruneStaleCacheFor(sessions) {
132
+ // a fresh live list is authoritative for its WHOLE storage (the stub fans
133
+ // out over every project directory). Any cache entry whose sessions mostly
134
+ // appear in it belongs to the same storage and only contributes sessions
135
+ // the live list no longer has — i.e. deleted ghosts. Drop it.
136
+ const liveIds = new Set(sessions.map((s) => s?.id ?? s?.sessionID).filter(Boolean));
137
+ if (!liveIds.size) return;
138
+ for (const [key, e] of sessionCache) {
139
+ const ids = (e.sessions || []).map((s) => s?.id ?? s?.sessionID).filter(Boolean);
140
+ if (!ids.length) continue;
141
+ const overlap = ids.filter((id) => liveIds.has(id)).length;
142
+ if (overlap / ids.length >= 0.5) sessionCache.delete(key);
143
+ }
144
+ }
145
+
146
+ function cacheInstance(info, sessions) {
147
+ if (!info || !Array.isArray(sessions) || sessions.length === 0) return;
148
+ pruneStaleCacheFor(sessions);
149
+ const key = info.directory || info.id;
150
+ if (!key) return;
151
+ sessionCache.set(key, { info: { ...info, id: key }, sessions, at: Date.now() });
152
+ persistSessionCache();
153
+ }
154
+
155
+ function pruneDeletedSession(gone) {
156
+ // a deleted session must not survive anywhere — not in attached instances'
157
+ // snapshots and especially not in the offline cache, where it would keep
158
+ // surfacing as an unopenable "offline" row on the phone
159
+ let changed = false;
160
+ const hid = (s) => s?.id ?? s?.sessionID;
161
+ for (const inst of instances.values()) {
162
+ if (Array.isArray(inst.sessions) && inst.sessions.some((s) => hid(s) === gone)) {
163
+ inst.sessions = inst.sessions.filter((s) => hid(s) !== gone);
164
+ changed = true;
165
+ }
166
+ }
167
+ for (const [key, e] of sessionCache) {
168
+ if (Array.isArray(e.sessions) && e.sessions.some((s) => hid(s) === gone)) {
169
+ e.sessions = e.sessions.filter((s) => hid(s) !== gone);
170
+ if (!e.sessions.length) sessionCache.delete(key);
171
+ changed = true;
172
+ }
173
+ }
174
+ if (changed) {
175
+ persistSessionCache();
176
+ broadcastInstances();
177
+ }
178
+ }
179
+
180
+ function phoneInfo(ws) {
181
+ return { id: ws.__id, ua: ws.__ua, connectedAt: ws.__connectedAt };
182
+ }
183
+
184
+ function instanceList() {
185
+ const live = [...instances.values()].map((i) => ({
186
+ ...(i.info || {}),
187
+ sessions: i.sessions || [],
188
+ attached: true,
189
+ }));
190
+ // every attached instance lists the whole storage (cross-directory), so the
191
+ // same session would reach the phone once per instance. Assign each session
192
+ // to exactly ONE: its own directory's instance when attached, else the
193
+ // earliest instance that lists it; cached (offline) entries come last.
194
+ const byDir = new Map(live.map((i, idx) => [i.directory, idx]));
195
+ const claims = new Map(); // sessionID -> { rank, idx, session }
196
+ const claim = (s, idx, ownsDir) => {
197
+ const sid = s?.id ?? s?.sessionID;
198
+ if (!sid) return;
199
+ const rank = (ownsDir ? 0 : 1) * 100000 + idx;
200
+ const prev = claims.get(sid);
201
+ if (!prev || rank < prev.rank) claims.set(sid, { rank, idx, session: s });
202
+ };
203
+ live.forEach((i, idx) => {
204
+ for (const s of i.sessions) claim(s, idx, !!s.directory && byDir.get(s.directory) === idx);
205
+ });
206
+ const covered = new Set();
207
+ for (const i of live) {
208
+ if (i.directory) covered.add(i.directory);
209
+ for (const s of i.sessions) if (s.directory) covered.add(s.directory);
210
+ }
211
+ const cached = [];
212
+ for (const [key, e] of sessionCache) {
213
+ if (covered.has(e.info?.directory)) continue;
214
+ cached.push({ key, e, idx: live.length + cached.length, sessions: [] });
215
+ }
216
+ for (const c of cached) for (const s of c.e.sessions) claim(s, c.idx, false);
217
+ const out = live.map((i) => ({ ...i, sessions: [] }));
218
+ for (const { idx, session } of claims.values()) {
219
+ if (idx < out.length) out[idx].sessions.push(session);
220
+ else cached[idx - out.length].sessions.push(session);
221
+ }
222
+ for (const c of cached) {
223
+ if (c.sessions.length) out.push({ ...c.e.info, id: c.key, sessions: c.sessions, attached: false });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function writeState(port, localPort = LOCAL_PORT) {
229
+ try {
230
+ fs.mkdirSync(DATA_DIR, { recursive: true });
231
+ const tmp = path.join(DATA_DIR, `.state-${process.pid}.tmp`);
232
+ const state = {
233
+ version: VERSION,
234
+ pid: process.pid,
235
+ port,
236
+ localPort,
237
+ lanIp: advertisedIp,
238
+ startedAt: STARTED_AT,
239
+ words: daemonWords(),
240
+ clients: [...phones].map(phoneInfo),
241
+ instances: [...instances.values()].map((i) => ({
242
+ id: i.info?.id, directory: i.info?.directory, project: i.info?.project, pid: i.info?.pid,
243
+ })),
244
+ };
245
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
246
+ fs.renameSync(tmp, daemonStatePath());
247
+ } catch (err) {
248
+ log("writeState failed:", String(err?.message || err));
249
+ }
250
+ }
251
+
252
+ function broadcast(msg, only) {
253
+ for (const ws of phones) {
254
+ if (only && ws !== only) continue;
255
+ phoneSend(ws, msg);
256
+ }
257
+ }
258
+
259
+ // phones speak only the encrypted protocol: hello/rpc/events ride inside
260
+ // {t:"sec", n, ct} AES-256-GCM frames (see src/e2ee.js). A socket that has
261
+ // not finished hs1 has no keys yet — it gets NOTHING, not even broadcasts,
262
+ // otherwise an unauthenticated LAN/relay peer could passively read frames.
263
+ function phoneSend(ws, msg) {
264
+ try {
265
+ if (!ws.__sec) return; // never speak plaintext to a phone socket
266
+ const { dp, sendN } = ws.__sec;
267
+ ws.__sec.sendN++;
268
+ ws.send(JSON.stringify({ t: "sec", n: sendN, ct: frameEncrypt(dp, DP_TAG, sendN, msg) }));
269
+ } catch {}
270
+ }
271
+
272
+ function broadcastInstances() {
273
+ broadcast({ t: "instances", instances: instanceList() });
274
+ }
275
+
276
+ function requestInstanceSessions(inst) {
277
+ return new Promise((resolve) => {
278
+ const id = `pull-${crypto.randomUUID()}`;
279
+ inst.__pulls = inst.__pulls || new Map();
280
+ const done = (v) => { inst.__pulls.delete(id); resolve(v); };
281
+ const timer = setTimeout(() => done(null), 2500);
282
+ inst.__pulls.set(id, (result) => { clearTimeout(timer); done(result); });
283
+ try {
284
+ inst.ws.send(JSON.stringify({ t: "rpc", id, method: "session.list", args: {} }));
285
+ } catch {
286
+ clearTimeout(timer);
287
+ done(null);
288
+ }
289
+ });
290
+ }
291
+
292
+ let pullPromise = null;
293
+ let lastPullAt = 0;
294
+ function pullInstanceSessions() {
295
+ if (pullPromise) return pullPromise;
296
+ if (Date.now() - lastPullAt < 700) return Promise.resolve();
297
+ pullPromise = (async () => {
298
+ await Promise.all([...instances.values()].map(async (inst) => {
299
+ const fresh = await requestInstanceSessions(inst);
300
+ if (Array.isArray(fresh)) {
301
+ inst.sessions = fresh;
302
+ cacheInstance(inst.info, fresh);
303
+ }
304
+ }));
305
+ })();
306
+ return pullPromise.finally(() => { pullPromise = null; lastPullAt = Date.now(); });
307
+ }
308
+
309
+ // ---------- static ----------
310
+ function asset(name, file, type) {
311
+ try {
312
+ return { body: fs.readFileSync(path.join(daemonWebDir(), file)), type };
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ const ASSETS = {
319
+ "/": ["index.html", "text/html; charset=utf-8"],
320
+ "/index.html": ["index.html", "text/html; charset=utf-8"],
321
+ "/app.js": ["app.js.txt", "application/javascript; charset=utf-8"],
322
+ "/app.css": ["app.css.txt", "text/css; charset=utf-8"],
323
+ "/pair": ["pair.html", "text/html; charset=utf-8"],
324
+ "/pair.html": ["pair.html", "text/html; charset=utf-8"],
325
+ };
326
+
327
+ // self-signed TLS cert — phones need a SECURE CONTEXT for WebCrypto, so the
328
+ // phone-facing server speaks https. Accepted-once in the browser; the pinned
329
+ // daemon key from pairing still guards against TLS MITM.
330
+ function ensureCert(ip) {
331
+ const certP = path.join(DATA_DIR, "cert.pem");
332
+ const keyP = path.join(DATA_DIR, "cert.key.pem");
333
+ const metaP = path.join(DATA_DIR, "cert.meta.json");
334
+ let meta = null;
335
+ try { meta = JSON.parse(fs.readFileSync(metaP, "utf8")); } catch {}
336
+ if (meta?.sanIp === (ip ?? null) && fs.existsSync(certP) && fs.existsSync(keyP)) {
337
+ return { cert: fs.readFileSync(certP), key: fs.readFileSync(keyP) };
338
+ }
339
+ const san = `subjectAltName=DNS:localhost,IP:127.0.0.1${ip ? `,IP:${ip}` : ""}`;
340
+ fs.mkdirSync(DATA_DIR, { recursive: true });
341
+ const out = spawnSync("openssl", [
342
+ "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
343
+ "-keyout", keyP, "-out", certP, "-days", "3650", "-nodes",
344
+ "-subj", "/CN=ocduet", "-addext", san,
345
+ ], { stdio: "ignore" });
346
+ if (out.status !== 0) throw new Error("openssl cert generation failed — is openssl installed?");
347
+ try { fs.chmodSync(keyP, 0o600); } catch {}
348
+ fs.writeFileSync(metaP, JSON.stringify({ sanIp: ip ?? null, at: Date.now() }));
349
+ log("generated tls cert for", san);
350
+ return { cert: fs.readFileSync(certP), key: fs.readFileSync(keyP) };
351
+ }
352
+
353
+ // ---------- server ----------
354
+ const onRequest = (req, res) => {
355
+ const url = new URL(req.url, "http://x");
356
+ if (url.pathname === "/status") {
357
+ // full detail only for direct loopback callers (CLI/tests/stub health
358
+ // checks). LAN peers and relayed requests (marked by the relay client
359
+ // with x-ocduet-via-relay) get the bare minimum — pid, ports, lanIp,
360
+ // instance counts and relay coordinates are nobody's business off-box
361
+ const loop = /^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/.test(req.socket.remoteAddress || "");
362
+ const relayed = req.headers["x-ocduet-via-relay"] === "1";
363
+ const body = loop && !relayed ? {
364
+ app: "ocduet", daemon: true, ok: true, version: VERSION,
365
+ instances: instances.size, clients: phones.size,
366
+ pid: process.pid, port: PORT, localPort: LOCAL_PORT, lanIp: advertisedIp, startedAt: STARTED_AT,
367
+ relay: relayStatus(),
368
+ } : { app: "ocduet", daemon: true, ok: true, version: VERSION };
369
+ res.writeHead(200, { "content-type": "application/json" });
370
+ res.end(JSON.stringify(body));
371
+ return;
372
+ }
373
+ if (url.pathname === "/pair/info") {
374
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
375
+ res.end(JSON.stringify({ pub: daemonPub().toString("base64"), words: daemonWords() }));
376
+ return;
377
+ }
378
+ if (url.pathname === "/pair/register" && req.method === "POST") {
379
+ let body = "";
380
+ req.on("data", (c) => { body += c; if (body.length > 8192) req.destroy(); });
381
+ req.on("end", () => {
382
+ const fail = (why) => { res.writeHead(403, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: false, error: why })); };
383
+ try {
384
+ const m = JSON.parse(body || "{}");
385
+ const phoneId = String(m.phoneId || "");
386
+ const phonePub = String(m.phonePub || "");
387
+ const pubBuf = Buffer.from(phonePub, "base64");
388
+ if (!phoneId || phoneId.length > 64) return fail("bad phoneId");
389
+ if (pubBuf.length !== 32) return fail("bad phonePub");
390
+ if (!tokenOk(m.token)) return fail("bad token");
391
+ if (!verifySig(pubBuf, pairMsg(phoneId, phonePub, m.token), m.sig)) return fail("bad signature");
392
+ addPhone({ id: phoneId, pub: phonePub });
393
+ log("phone paired:", phoneId);
394
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
395
+ res.end(JSON.stringify({ ok: true }));
396
+ } catch {
397
+ fail("bad request");
398
+ }
399
+ });
400
+ return;
401
+ }
402
+ const a = ASSETS[url.pathname] ? asset(url.pathname, ASSETS[url.pathname][0], ASSETS[url.pathname][1]) : null;
403
+ if (a) {
404
+ res.writeHead(200, { "content-type": a.type, "cache-control": "no-store" });
405
+ res.end(a.body);
406
+ return;
407
+ }
408
+ res.writeHead(404);
409
+ res.end("not found");
410
+ };
411
+
412
+ const wss = new WebSocketServer({ noServer: true });
413
+ const onUpgrade = (req, socket, head) => {
414
+ const url = new URL(req.url, "http://x");
415
+ const token = url.searchParams.get("t") || req.headers.authorization?.replace(/^Bearer\s+/i, "");
416
+ const internal = url.pathname === "/internal";
417
+ if (internal) {
418
+ // localhost stub hop — token auth, plaintext is fine on loopback
419
+ if (!tokenOk(token)) {
420
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
421
+ socket.destroy();
422
+ return;
423
+ }
424
+ } else if (url.searchParams.get("e2ee") !== "1") {
425
+ // phone hop must be encrypted — no plaintext phone protocol anymore
426
+ socket.write("HTTP/1.1 426 Upgrade Required\r\n\r\nthis daemon requires end-to-end encryption; pair the phone via the QR url");
427
+ socket.destroy();
428
+ return;
429
+ }
430
+ wss.handleUpgrade(req, socket, head, (ws) => {
431
+ wss.emit(internal ? "internal" : "phone", ws, req);
432
+ });
433
+ };
434
+
435
+ // phones: https (secure context -> WebCrypto). stubs/CLI: loopback http.
436
+ let LOCAL_PORT = null;
437
+ const bootCert = ensureCert(lanIp());
438
+ const server = https.createServer({ key: bootCert.key, cert: bootCert.cert }, onRequest);
439
+ const localServer = http.createServer(onRequest);
440
+ server.on("upgrade", onUpgrade);
441
+ localServer.on("upgrade", onUpgrade);
442
+
443
+ // ---- phone connections (encrypted; handshake then the old protocol inside) ----
444
+ function handlePhoneMessage(ws, msg) {
445
+ if (msg.t !== "rpc" || !msg.id) return;
446
+
447
+ if (msg.method === "instances.list") {
448
+ (async () => {
449
+ await pullInstanceSessions();
450
+ broadcastInstances();
451
+ phoneSend(ws, { t: "rpcResult", id: msg.id, ok: true, result: instanceList() });
452
+ })();
453
+ return;
454
+ }
455
+ const inst = instances.get(msg.instanceId);
456
+ if (!inst) {
457
+ phoneSend(ws, { t: "rpcResult", id: msg.id, ok: false, error: "instance offline" });
458
+ return;
459
+ }
460
+ inst.__pending = inst.__pending || new Map();
461
+ inst.__pending.set(msg.id, { ws, method: msg.method, args: msg.args || {} });
462
+ try {
463
+ inst.ws.send(JSON.stringify({ t: "rpc", id: msg.id, method: msg.method, args: msg.args || {} }));
464
+ } catch (err) {
465
+ inst.__pending.delete(msg.id);
466
+ phoneSend(ws, { t: "rpcResult", id: msg.id, ok: false, error: String(err?.message || err) });
467
+ }
468
+ }
469
+
470
+ // relay coordinates for phones: sent inside the encrypted hello so an
471
+ // already-paired phone can learn/migrate to the relay route without a new QR
472
+ function relayRouteInfo() {
473
+ const s = relayStatus();
474
+ if (!s.linked) return null;
475
+ const cfg = readRelayConfig();
476
+ return { url: cfg.url, desktopId: cfg.desktopId, connected: s.connected };
477
+ }
478
+
479
+ function handleHandshake(ws, msg) { const close = (code, why) => { try { ws.close(code, why); } catch {} };
480
+ if (msg?.t !== "hs1") return close(4001, "expected hs1");
481
+ const ts = Number(msg.ts) || 0;
482
+ if (Math.abs(Date.now() - ts) > 120_000) return close(4001, "stale handshake");
483
+ const phonePub = String(msg.phonePub || "");
484
+ const ephB64 = String(msg.eph || "");
485
+ if (Buffer.from(phonePub, "base64").length !== 32) return close(4001, "bad phonePub");
486
+ if (Buffer.from(ephB64, "base64").length !== 32) return close(4001, "bad eph");
487
+ if (!tokenOk(msg.token)) return close(4003, "bad token");
488
+ const phone = phoneByPub(Buffer.from(phonePub, "base64"));
489
+ if (!phone) return close(4003, "unknown phone — pair it first");
490
+ if (!verifySig(Buffer.from(phonePub, "base64"), hs1Msg(phonePub, ephB64, msg.token, msg.ts), msg.sig)) {
491
+ return close(4003, "bad signature");
492
+ }
493
+ const eph = ephX25519();
494
+ const ephB64d = eph.pub.toString("base64");
495
+ const shared = ecdh(eph.priv, Buffer.from(ephB64, "base64"));
496
+ const keys = deriveKeys(shared, hsInfo(phonePub, ephB64, ephB64d));
497
+ ws.__sec = { pd: keys.pd, dp: keys.dp, sendN: 0, recvN: 0, pending: sha256hex(ephB64d) };
498
+ ws.send(JSON.stringify({ t: "hs2", eph: ephB64d, sig: signDaemon(hs2Msg(phonePub, ephB64, ephB64d)) }));
499
+ }
500
+
501
+ function sha256hex(s) {
502
+ return crypto.createHash("sha256").update(s).digest("hex");
503
+ }
504
+
505
+ wss.on("phone", (ws) => {
506
+ ws.__id = crypto.randomUUID().slice(0, 8);
507
+ ws.__ua = "phone";
508
+ ws.__connectedAt = Date.now();
509
+ ws.alive = true;
510
+ phones.add(ws);
511
+ const hsTimer = setTimeout(() => { if (!ws.__sec || ws.__sec.pending) try { ws.close(4001, "handshake timeout"); } catch {} }, 10_000);
512
+ ws.on("pong", () => { ws.alive = true; });
513
+ ws.on("message", (raw) => {
514
+ let msg;
515
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
516
+ if (!ws.__sec) return handleHandshake(ws, msg);
517
+ if (msg?.t !== "sec") return; // no plaintext after hs1
518
+ try {
519
+ if (ws.__sec.pending) {
520
+ if (msg.n !== 0) throw new Error("bad hs3 counter");
521
+ const m = frameDecrypt(ws.__sec.pd, PD_TAG, 0, msg.ct);
522
+ if (m?.t !== "hs3" || m.echo !== ws.__sec.pending) throw new Error("bad hs3");
523
+ delete ws.__sec.pending;
524
+ clearTimeout(hsTimer);
525
+ ws.__sec.recvN = 1;
526
+ phoneSend(ws, { t: "hs4", ok: true }); // dp frame at n=0
527
+ phoneSend(ws, {
528
+ t: "hello",
529
+ version: VERSION,
530
+ lanIp: advertisedIp,
531
+ port: PORT,
532
+ relay: relayRouteInfo(),
533
+ instances: instanceList(),
534
+ clients: [...phones].map(phoneInfo),
535
+ });
536
+ broadcast({ t: "clients", clients: [...phones].map(phoneInfo) }, undefined);
537
+ writeState(PORT);
538
+ return;
539
+ }
540
+ if (msg.n !== ws.__sec.recvN) throw new Error("replay/out-of-order frame");
541
+ ws.__sec.recvN++;
542
+ handlePhoneMessage(ws, frameDecrypt(ws.__sec.pd, PD_TAG, msg.n, msg.ct));
543
+ } catch (err) {
544
+ log("phone frame rejected:", String(err?.message || err));
545
+ try { ws.close(4002, "bad frame"); } catch {}
546
+ }
547
+ });
548
+ ws.on("close", () => {
549
+ clearTimeout(hsTimer);
550
+ phones.delete(ws);
551
+ broadcast({ t: "clients", clients: [...phones].map(phoneInfo) });
552
+ writeState(PORT);
553
+ });
554
+ });
555
+
556
+ // ---- plugin (internal) connections ----
557
+ wss.on("internal", (ws) => {
558
+ ws.alive = true;
559
+ let registeredId = null;
560
+ ws.on("pong", () => { ws.alive = true; });
561
+ ws.on("message", (raw) => {
562
+ let msg;
563
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
564
+ if (msg.t === "register") {
565
+ registeredId = msg.instance?.id;
566
+ if (!registeredId) return;
567
+ instances.set(registeredId, { ws, info: msg.instance, sessions: msg.sessions || [] });
568
+ cacheInstance(msg.instance, msg.sessions);
569
+ log("instance attached:", registeredId);
570
+ broadcastInstances();
571
+ writeState(PORT);
572
+ } else if (msg.t === "event") {
573
+ if (msg.event?.type === "session.deleted") {
574
+ const p = msg.event.properties ?? {};
575
+ const gone = p.info?.id ?? p.sessionID ?? p.id;
576
+ if (gone) pruneDeletedSession(String(gone));
577
+ }
578
+ broadcast({ t: "event", instanceId: registeredId, event: msg.event });
579
+ } else if (msg.t === "sessions" && registeredId) {
580
+ const inst = instances.get(registeredId);
581
+ if (inst) {
582
+ inst.sessions = msg.sessions || [];
583
+ cacheInstance(inst.info, inst.sessions);
584
+ broadcastInstances();
585
+ }
586
+ } else if (msg.t === "rpcResult" && msg.id) {
587
+ const inst = registeredId && instances.get(registeredId);
588
+ const pullCb = inst?.__pulls?.get(msg.id);
589
+ if (pullCb) {
590
+ inst.__pulls.delete(msg.id);
591
+ pullCb(msg.ok ? msg.result : null);
592
+ return;
593
+ }
594
+ const rec = inst?.__pending?.get(msg.id);
595
+ if (rec) {
596
+ inst.__pending.delete(msg.id);
597
+ // belt & suspenders: a successful delete RPC prunes even if the
598
+ // session.deleted event never reaches the daemon
599
+ if (msg.ok && rec.method === "session.delete" && rec.args?.id) {
600
+ pruneDeletedSession(String(rec.args.id));
601
+ }
602
+ phoneSend(rec.ws, { t: "rpcResult", id: msg.id, ok: !!msg.ok, ...(msg.ok ? { result: msg.result } : { error: msg.error }) });
603
+ }
604
+ }
605
+ });
606
+ const drop = () => {
607
+ if (registeredId && instances.get(registeredId)?.ws === ws) {
608
+ instances.delete(registeredId);
609
+ log("instance detached:", registeredId);
610
+ broadcastInstances();
611
+ writeState(PORT);
612
+ }
613
+ };
614
+ ws.on("close", drop);
615
+ });
616
+
617
+ // ---- keepalive: protocol pings; browsers pong automatically ----
618
+ setInterval(() => {
619
+ for (const ws of phones) {
620
+ if (!ws.alive) { try { ws.terminate(); } catch {} continue; }
621
+ ws.alive = false;
622
+ try { ws.ping(); } catch {}
623
+ }
624
+ for (const inst of instances.values()) {
625
+ const ws = inst.ws;
626
+ if (!ws.alive) { try { ws.terminate(); } catch {} continue; }
627
+ ws.alive = false;
628
+ try { ws.ping(); } catch {}
629
+ }
630
+ }, 30_000).unref();
631
+
632
+ // ---- bind (step ports like the plugin did) ----
633
+ function healthyDaemonOn(port) {
634
+ return fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(1000) })
635
+ .then(async (r) => {
636
+ if (!r.ok) return false;
637
+ try { return (await r.json())?.daemon === true; } catch { return false; }
638
+ })
639
+ .catch(() => false);
640
+ }
641
+
642
+ let PORT = null;
643
+ {
644
+ for (let p = BASE_PORT; p < BASE_PORT + 20; p++) {
645
+ if ((await healthyDaemonOn(p)) || (await healthyDaemonOn(p + 1))) {
646
+ log(`another ocduet daemon already healthy near port ${p}; exiting`);
647
+ try {
648
+ if (Number(fs.readFileSync(daemonPidPath(), "utf8")) === process.pid) fs.rmSync(daemonPidPath());
649
+ } catch {}
650
+ process.exit(0);
651
+ }
652
+ }
653
+ let lastErr = null;
654
+ for (let p = BASE_PORT; p < BASE_PORT + 20 && PORT === null; p++) {
655
+ lastErr = null;
656
+ const ok = await new Promise((resolve) => {
657
+ const onError = (err) => { lastErr = err; resolve(false); };
658
+ server.once("error", onError);
659
+ server.listen(p, HOST, () => {
660
+ server.removeListener("error", onError);
661
+ server.on("error", (err) => log("server error:", String(err?.message || err)));
662
+ resolve(true);
663
+ });
664
+ });
665
+ if (ok) PORT = p;
666
+ else if ((await healthyDaemonOn(p)) || (await healthyDaemonOn(p + 1))) {
667
+ // a rival daemon bound the port between our health sweep and this bind
668
+ log(`another ocduet daemon took port ${p} during startup; exiting`);
669
+ try {
670
+ if (Number(fs.readFileSync(daemonPidPath(), "utf8")) === process.pid) fs.rmSync(daemonPidPath());
671
+ } catch {}
672
+ process.exit(0);
673
+ }
674
+ }
675
+ if (PORT === null) {
676
+ log("FATAL: no free port:", String(lastErr?.message || lastErr));
677
+ process.exit(1);
678
+ }
679
+ // loopback http for stubs + CLI (they verify via the pairing token; plaintext loopback)
680
+ for (let q = PORT + 1; q <= PORT + 5; q++) {
681
+ const ok = await new Promise((resolve) => {
682
+ localServer.once("error", () => resolve(false));
683
+ localServer.listen(q, "127.0.0.1", () => resolve(true));
684
+ });
685
+ if (ok) { LOCAL_PORT = q; break; }
686
+ }
687
+ if (LOCAL_PORT === null) {
688
+ log("FATAL: no free loopback port");
689
+ process.exit(1);
690
+ }
691
+ }
692
+
693
+ loadKeys(); // identity exists before anything reports words
694
+ ensureToken();
695
+ // tighten files created by older versions (0644) — state/cache rewrite with
696
+ // mode 0600 anyway, the sweep covers daemon.log and anything not rewritten yet
697
+ for (const f of [daemonLogPath(), SESSIONS_CACHE_FILE, daemonStatePath()]) {
698
+ try { fs.chmodSync(f, 0o600); } catch {}
699
+ }
700
+ fs.mkdirSync(DATA_DIR, { recursive: true });
701
+ fs.writeFileSync(PORT_FILE, String(LOCAL_PORT), { mode: 0o600 });
702
+ fs.writeFileSync(MAIN_PORT_FILE, String(PORT), { mode: 0o600 });
703
+ fs.writeFileSync(daemonPidPath(), String(process.pid), { mode: 0o600 });
704
+ advertisedIp = lanIp();
705
+ writeState(PORT);
706
+ log(`ocduet daemon: https on ${HOST}:${PORT} (lan ${lanIp()}), loopback http on 127.0.0.1:${LOCAL_PORT}`);
707
+
708
+ // relayer tunnel: keeps this desktop reachable through a public relay when
709
+ // the phone is off-LAN. Purely additive — no-op unless `ocduet relay link`
710
+ // wrote an identity. The relay only ever sees ciphertext (see src/relayer.js).
711
+ const relay = startRelayClient({ localPort: LOCAL_PORT });
712
+ if (relay) log(`relay client started: ${readRelayConfig().url} (desktop id ${readRelayConfig().desktopId})`);
713
+ process.on("exit", () => { try { relay && relay.stop(); } catch {} });
714
+
715
+ // Listen on all interfaces; DHCP/Wi-Fi changes need no socket or daemon restart.
716
+ // Sidebar polling notices the updated state and redraws even an already-open QR.
717
+ const networkPoll = setInterval(() => {
718
+ const next = lanIp();
719
+ const changed = next !== advertisedIp;
720
+ if (changed) {
721
+ advertisedIp = next;
722
+ log(`LAN address changed: ${next ?? "offline"}`);
723
+ try {
724
+ const c = ensureCert(next);
725
+ server.setSecureContext({ key: c.key, cert: c.cert });
726
+ } catch (err) {
727
+ log("cert regen failed:", String(err?.message || err));
728
+ }
729
+ }
730
+ if (changed || !fs.existsSync(daemonStatePath())) writeState(PORT);
731
+ }, NETWORK_POLL_MS);
732
+ networkPoll.unref();
733
+
734
+ const cleanup = () => {
735
+ clearInterval(networkPoll);
736
+ // A retiring process must not remove a replacement daemon's discovery files.
737
+ try {
738
+ if (JSON.parse(fs.readFileSync(daemonStatePath(), "utf8")).pid === process.pid) fs.rmSync(daemonStatePath());
739
+ } catch {}
740
+ try {
741
+ if (Number(fs.readFileSync(daemonPidPath(), "utf8")) === process.pid) fs.rmSync(daemonPidPath());
742
+ } catch {}
743
+ };
744
+ process.on("exit", cleanup);
745
+ for (const sig of ["SIGTERM", "SIGINT"]) {
746
+ process.on(sig, () => { cleanup(); process.exit(0); });
747
+ }