@decentnetwork/beagle 0.1.0

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/dist/server.js ADDED
@@ -0,0 +1,1187 @@
1
+ //
2
+ // Friend UI — a tiny local web page for managing a peer's friends:
3
+ // 1. display the friend list (name / status / userid)
4
+ // 2. display incoming friend-requests, with Accept / Reject
5
+ // 3. add a friend by address
6
+ //
7
+ // It's a thin frontend over the daemon's existing IPC ops (diag,
8
+ // friends-pending, friends-accept, friends-reject, friend-request) — no new
9
+ // daemon logic. Started with `agentnet ui`; point a browser at the printed URL.
10
+ //
11
+ // Note: requests only QUEUE for manual Accept/Reject when the daemon runs with
12
+ // `friends.autoAccept = false`. With auto-accept on, the pending list stays
13
+ // empty because the daemon accepts immediately.
14
+ //
15
+ import http from "node:http";
16
+ import net from "node:net";
17
+ import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { dirname, join } from "node:path";
20
+ import yaml from "js-yaml";
21
+ import { DEFAULT_EXITS } from "./exits.js";
22
+ // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
23
+ // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
24
+ const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
25
+ // Exit reachability probe: the CONNECT-proxy port every exit runs, and how long
26
+ // to wait before calling an exit unreachable. Kept short so the network panel
27
+ // (which probes all exits on each poll) stays responsive over a lossy mesh.
28
+ const EXIT_PROBE_PORT = 8888;
29
+ const EXIT_PROBE_TIMEOUT_MS = 2000;
30
+ /** True only when the request came from the local machine. Used to gate the
31
+ * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
32
+ * identity signing to other hosts. The popup always runs in the local user's
33
+ * browser, so it connects over the loopback interface. */
34
+ function isLocalRequest(req) {
35
+ const a = req.socket.remoteAddress ?? "";
36
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
37
+ }
38
+ /**
39
+ * Reveal a file in the OS file manager (Finder / Explorer / Nautilus), so the
40
+ * user can play, share, AirDrop, copy or delete it with native tools instead
41
+ * of re-downloading — the file is already on disk under downloadsDir.
42
+ *
43
+ * Two things make this fiddly:
44
+ * - The daemon usually runs as ROOT (started via sudo). A GUI command run as
45
+ * root either opens in root's own (invisible) session or is refused. On
46
+ * macOS the fix is `launchctl asuser <uid>` to hop into the logged-in
47
+ * user's Aqua session; elsewhere we drop to `SUDO_USER` with `sudo -u`.
48
+ * - WSL has no Linux file manager but can drive Windows Explorer via the
49
+ * translated \\wsl$ path.
50
+ *
51
+ * Best-effort: returns {ok:false, error} rather than throwing so the caller
52
+ * can surface a message. `filePath` MUST already be validated as inside
53
+ * downloadsDir by the caller.
54
+ */
55
+ async function revealInFileManager(filePath) {
56
+ const { spawn } = await import("node:child_process");
57
+ const run = (cmd, args) => new Promise((resolve) => {
58
+ let child;
59
+ try {
60
+ child = spawn(cmd, args, { stdio: "ignore", detached: true });
61
+ }
62
+ catch (e) {
63
+ resolve({ ok: false, error: `${cmd}: ${e.message}` });
64
+ return;
65
+ }
66
+ child.on("error", (e) => resolve({ ok: false, error: `${cmd}: ${e.message}` }));
67
+ // Windows Explorer returns exit code 1 even on success, so treat spawn
68
+ // without an 'error' event as success rather than gating on the code.
69
+ child.unref();
70
+ setTimeout(() => resolve({ ok: true }), 150);
71
+ });
72
+ const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
73
+ const sudoUser = process.env.SUDO_USER;
74
+ const sudoUid = process.env.SUDO_UID;
75
+ if (process.platform === "darwin") {
76
+ // Run `open -R` inside the login user's GUI session when we're root.
77
+ if (asRoot && sudoUid) {
78
+ return run("launchctl", ["asuser", sudoUid, "open", "-R", filePath]);
79
+ }
80
+ return run("open", ["-R", filePath]);
81
+ }
82
+ if (process.platform === "win32") {
83
+ return run("explorer.exe", [`/select,${filePath}`]);
84
+ }
85
+ // Linux (incl. WSL). WSL: drive Windows Explorer with the \\wsl$ UNC path.
86
+ const isWsl = existsSync("/proc/version") &&
87
+ (await import("node:fs/promises")).readFile("/proc/version", "utf-8").then((v) => v.toLowerCase().includes("microsoft"), () => false);
88
+ if (await isWsl) {
89
+ let winPath = filePath;
90
+ try {
91
+ const { execFileSync } = await import("node:child_process");
92
+ winPath = execFileSync("wslpath", ["-w", filePath], { encoding: "utf-8" }).trim() || filePath;
93
+ }
94
+ catch {
95
+ // wslpath missing — fall through with the raw path
96
+ }
97
+ return run("explorer.exe", [`/select,${winPath}`]);
98
+ }
99
+ // A headless server has no file manager; xdg-open on the parent folder is the
100
+ // best we can do where a desktop exists. Drop privileges to the login user.
101
+ const dir = filePath.slice(0, filePath.lastIndexOf("/")) || "/";
102
+ if (asRoot && sudoUser)
103
+ return run("sudo", ["-u", sudoUser, "xdg-open", dir]);
104
+ return run("xdg-open", [dir]);
105
+ }
106
+ /** Validate and normalize a website origin (scheme://host[:port], no path).
107
+ * Returns the canonical origin string or null if malformed. Only http/https
108
+ * are accepted. */
109
+ function validateOrigin(raw) {
110
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 256)
111
+ return null;
112
+ try {
113
+ const u = new URL(raw);
114
+ if (u.protocol !== "http:" && u.protocol !== "https:")
115
+ return null;
116
+ // u.origin drops any path/query/hash and lowercases the host.
117
+ if (u.origin === "null" || !u.origin)
118
+ return null;
119
+ return u.origin;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ // The "Sign in with Decent" consent popup. Self-contained (no external assets),
126
+ // dark theme matching the desktop UI. Reads origin+nonce from its own query,
127
+ // shows the requesting site and this node's identity, and on Approve calls the
128
+ // local /api/connect-approve to sign, then postMessages the result to the
129
+ // opener at the exact requesting origin. Mandatory explicit consent — never
130
+ // returns identity without an Approve click.
131
+ const CONNECT_PAGE = `<!doctype html>
132
+ <html lang="en"><head><meta charset="utf-8">
133
+ <meta name="viewport" content="width=device-width,initial-scale=1">
134
+ <title>Sign in with Decent</title>
135
+ <style>
136
+ :root{--bg:#0c0d11;--panel:#14161d;--line:#262a35;--text:#e7e9ef;--faint:#8b91a3;
137
+ --accent:#5b8cff;--good:#46a758;--bad:#e5604d;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
138
+ *{box-sizing:border-box}
139
+ html,body{margin:0;height:100%}
140
+ body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
141
+ display:flex;align-items:center;justify-content:center;padding:18px}
142
+ .card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--line);
143
+ border-radius:14px;padding:22px 20px}
144
+ .brand{font-weight:600;letter-spacing:.2px;color:var(--accent);font-size:13px;display:flex;align-items:center;gap:7px}
145
+ .brand .d{width:11px;height:11px;background:var(--accent);transform:rotate(45deg);border-radius:2px}
146
+ h1{font-size:18px;margin:14px 0 6px}
147
+ .sub{color:var(--faint);margin:0 0 16px}
148
+ .origin{color:var(--text);font-weight:600;word-break:break-all}
149
+ .id{background:#0e1016;border:1px solid var(--line);border-radius:10px;padding:11px 12px;margin-bottom:14px}
150
+ .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--faint);margin-bottom:4px}
151
+ .userid{font-family:var(--mono);font-size:12.5px;color:var(--text);word-break:break-all}
152
+ .err{background:rgba(229,96,77,.12);border:1px solid rgba(229,96,77,.4);color:#ffb4a8;
153
+ border-radius:9px;padding:9px 11px;font-size:12.5px;margin-bottom:13px}
154
+ .row{display:flex;gap:10px;margin-top:4px}
155
+ .btn{flex:1;border:0;border-radius:9px;padding:11px;font-size:14px;font-weight:600;cursor:pointer}
156
+ .ghost{background:transparent;border:1px solid var(--line);color:var(--text)}
157
+ .ghost:hover{border-color:#3a4050}
158
+ .solid{background:var(--accent);color:#fff}
159
+ .solid:disabled{opacity:.45;cursor:not-allowed}
160
+ .note{color:var(--faint);font-size:11.5px;margin:15px 0 0;line-height:1.45}
161
+ </style></head>
162
+ <body>
163
+ <div class="card">
164
+ <div class="brand"><span class="d"></span>Decent</div>
165
+ <h1>Sign in</h1>
166
+ <p class="sub"><span id="origin" class="origin">…</span> wants to sign you in with your Decent identity.</p>
167
+ <div class="id"><div class="lbl">Your identity</div><div id="userid" class="userid">loading…</div></div>
168
+ <div id="err" class="err" hidden></div>
169
+ <div class="row">
170
+ <button id="deny" class="btn ghost">Deny</button>
171
+ <button id="approve" class="btn solid" disabled>Approve</button>
172
+ </div>
173
+ <p class="note">Approving sends the site a signature bound to that site, proving you control this identity. Your private key never leaves this device.</p>
174
+ </div>
175
+ <script>
176
+ (function(){
177
+ var qs=new URLSearchParams(location.search);
178
+ var origin=qs.get("origin")||"", nonce=qs.get("nonce")||"";
179
+ var oEl=document.getElementById("origin"), errEl=document.getElementById("err");
180
+ var approve=document.getElementById("approve"), deny=document.getElementById("deny");
181
+ oEl.textContent=origin||"(unknown site)";
182
+ function fail(m){errEl.textContent=m;errEl.hidden=false;}
183
+ function reply(data){ if(window.opener&&validOrigin){ window.opener.postMessage(Object.assign({type:"decent-auth",nonce:nonce},data),origin);} }
184
+ var validOrigin=false;
185
+ try{var u=new URL(origin); validOrigin=(u.protocol==="http:"||u.protocol==="https:")&&u.origin===origin;}catch(e){}
186
+ if(!validOrigin) fail("This sign-in request has an invalid origin and was blocked.");
187
+ if(nonce.length===0||nonce.length>512){ validOrigin=false; fail("This sign-in request is missing a valid nonce."); }
188
+ fetch("/api/state").then(function(r){return r.json();}).then(function(s){
189
+ var uid=(s.me&&s.me.userid)||"";
190
+ document.getElementById("userid").textContent=uid||"(no identity)";
191
+ if(uid&&validOrigin) approve.disabled=false;
192
+ else if(!uid) fail("No local Decent identity found — is the daemon running?");
193
+ }).catch(function(){ fail("Could not reach the local agentnet daemon."); });
194
+ deny.onclick=function(){ reply({error:"denied"}); setTimeout(function(){window.close();},60); };
195
+ approve.onclick=function(){
196
+ approve.disabled=true; approve.textContent="Signing…";
197
+ fetch("/api/connect-approve",{method:"POST",headers:{"content-type":"application/json"},
198
+ body:JSON.stringify({origin:origin,nonce:nonce})})
199
+ .then(function(r){return r.json();})
200
+ .then(function(j){ if(!j.ok) throw new Error(j.error||"sign failed");
201
+ reply({userid:j.userid,sig:j.sig}); setTimeout(function(){window.close();},60); })
202
+ .catch(function(e){ approve.disabled=false; approve.textContent="Approve"; fail("Signing failed: "+e.message); });
203
+ };
204
+ })();
205
+ </script>
206
+ </body></html>`;
207
+ // ---- shapes the desktop UI (src/ui/desktop) consumes (DK_* in the design) ----
208
+ const fmtTime = (ts) => {
209
+ if (!ts)
210
+ return "";
211
+ const d = new Date(ts);
212
+ const today = new Date();
213
+ const sameDay = d.toDateString() === today.toDateString();
214
+ if (sameDay)
215
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
216
+ const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
217
+ const yest = new Date(today.getTime() - 86400000);
218
+ if (d.toDateString() === yest.toDateString())
219
+ return "Yest";
220
+ if (today.getTime() - d.getTime() < 7 * 86400000)
221
+ return days[d.getDay()];
222
+ return `${d.getDate()} ${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getMonth()]}`;
223
+ };
224
+ const viaFromTransport = (t) => t === "udp" || t === "both" ? "direct" : t === "tcp-relay" ? "relay" : null;
225
+ export function startBeagleServer(opts) {
226
+ const host = opts.listenHost ?? "127.0.0.1";
227
+ const port = opts.listenPort ?? 8765;
228
+ const log = opts.log ?? ((s) => console.log(s));
229
+ const sendJson = (res, code, body) => {
230
+ res.writeHead(code, { "content-type": "application/json" });
231
+ res.end(JSON.stringify(body));
232
+ };
233
+ const readBody = (req) => new Promise((resolve) => {
234
+ let b = "";
235
+ req.on("data", (c) => (b += c));
236
+ req.on("end", () => {
237
+ try {
238
+ resolve(b ? JSON.parse(b) : {});
239
+ }
240
+ catch {
241
+ resolve({});
242
+ }
243
+ });
244
+ });
245
+ // Named so the IPv6 loopback listener below can share it (Node binds one
246
+ // listener to one address, and `localhost` is two addresses).
247
+ const handler = async (req, res) => {
248
+ try {
249
+ const url = (req.url || "/").split("?")[0];
250
+ // Desktop UI bundle (the design). Falls back to the classic page when
251
+ // the bundle hasn't been built (dist/ui/desktop missing).
252
+ const desktopIndex = join(DESKTOP_DIR, "index.html");
253
+ if (req.method === "GET" && (url === "/" || url === "/index.html")) {
254
+ if (existsSync(desktopIndex)) {
255
+ // no-store: the UI bundle is rebuilt in place on every UI change, so the
256
+ // browser must never serve a stale cached index/app.js (that's why UI
257
+ // updates appeared to "not take" until a hard reload).
258
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
259
+ res.end(readFileSync(desktopIndex));
260
+ return;
261
+ }
262
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
263
+ res.end(PAGE);
264
+ return;
265
+ }
266
+ if (req.method === "GET" && (url === "/classic" || url === "/classic.html")) {
267
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
268
+ res.end(PAGE);
269
+ return;
270
+ }
271
+ // Static assets for the desktop bundle (app.js + vendored react UMD).
272
+ if (req.method === "GET" && (url === "/app.js" || url.startsWith("/vendor/"))) {
273
+ const rel = url === "/app.js" ? "app.js" : url.slice(1); // strip leading /
274
+ const file = join(DESKTOP_DIR, rel);
275
+ // Guard against path traversal: resolved file must stay under DESKTOP_DIR.
276
+ if (existsSync(file) && file.startsWith(DESKTOP_DIR)) {
277
+ // app.js AND vendor/peer-webrtc.js change on every rebuild / SDK bump
278
+ // → never cache them, or the browser keeps running stale call/UI code
279
+ // (this silently defeated every peer-webrtc fix). Only the truly stable
280
+ // vendored libs (React UMD) may cache.
281
+ const headers = { "content-type": "application/javascript; charset=utf-8" };
282
+ const volatile = rel === "app.js" || rel.includes("peer-webrtc");
283
+ headers["cache-control"] = volatile ? "no-store" : "public, max-age=31536000";
284
+ res.writeHead(200, headers);
285
+ res.end(readFileSync(file));
286
+ return;
287
+ }
288
+ res.writeHead(404);
289
+ res.end("not found");
290
+ return;
291
+ }
292
+ // ── Sign in with Decent ────────────────────────────────────────────
293
+ // A website opens http://localhost:8765/connect?origin=…&nonce=… in a
294
+ // popup; on Approve we sign "decent-auth\n<origin>\n<nonce>" with this
295
+ // node's Carrier identity and postMessage {userid,nonce,sig} back to the
296
+ // site, which verifies the signature against the userid. BOTH routes are
297
+ // LOCALHOST-ONLY: the popup always runs in the local user's browser
298
+ // (localhost), so binding the UI to a LAN IP must never let a remote
299
+ // host request signatures with this identity.
300
+ if (url === "/connect" || url === "/api/connect-approve") {
301
+ if (!isLocalRequest(req)) {
302
+ res.writeHead(403, { "content-type": "text/plain" });
303
+ res.end("Sign in with Decent is available only on this machine (localhost).");
304
+ return;
305
+ }
306
+ }
307
+ if (req.method === "GET" && url === "/connect") {
308
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
309
+ res.end(CONNECT_PAGE);
310
+ return;
311
+ }
312
+ if (req.method === "POST" && url === "/api/connect-approve") {
313
+ const body = await readBody(req);
314
+ const origin = validateOrigin(body.origin);
315
+ const nonce = typeof body.nonce === "string" ? body.nonce : "";
316
+ if (!origin) {
317
+ sendJson(res, 400, { ok: false, error: "invalid origin" });
318
+ return;
319
+ }
320
+ if (!nonce || nonce.length > 512) {
321
+ sendJson(res, 400, { ok: false, error: "invalid nonce" });
322
+ return;
323
+ }
324
+ // Bind BOTH the origin and the nonce into the signed message so a
325
+ // signature minted for site A can't be replayed at site B.
326
+ const message = `decent-auth\n${origin}\n${nonce}`;
327
+ const r = await opts.call({ op: "sign", text: message });
328
+ if (!r.ok) {
329
+ sendJson(res, 502, { ok: false, error: r.error || "sign failed" });
330
+ return;
331
+ }
332
+ sendJson(res, 200, {
333
+ ok: true,
334
+ userid: r.data?.userid ?? "",
335
+ sig: r.data?.sig ?? "",
336
+ });
337
+ return;
338
+ }
339
+ // Delete file/chat messages by id (removes their on-disk files too).
340
+ if (req.method === "POST" && url === "/api/file-delete") {
341
+ const body = await readBody(req);
342
+ const userid = typeof body.userid === "string" ? body.userid : "";
343
+ const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
344
+ if (!userid || !ids.length) {
345
+ sendJson(res, 400, { ok: false, error: "userid and ids required" });
346
+ return;
347
+ }
348
+ const r = await opts.call({ op: "file-delete", userid, ids });
349
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
350
+ return;
351
+ }
352
+ // Cancel in-flight sends by chat message id.
353
+ if (req.method === "POST" && url === "/api/file-cancel") {
354
+ const body = await readBody(req);
355
+ const userid = typeof body.userid === "string" ? body.userid : "";
356
+ const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
357
+ if (!userid || !ids.length) {
358
+ sendJson(res, 400, { ok: false, error: "userid and ids required" });
359
+ return;
360
+ }
361
+ const r = await opts.call({ op: "file-cancel", userid, ids });
362
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
363
+ return;
364
+ }
365
+ // Retry/resume incomplete sends from the daemon's retained outbox copy.
366
+ if (req.method === "POST" && url === "/api/file-retry") {
367
+ const body = await readBody(req);
368
+ const userid = typeof body.userid === "string" ? body.userid : "";
369
+ const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
370
+ if (!userid || !ids.length) {
371
+ sendJson(res, 400, { ok: false, error: "userid and ids required" });
372
+ return;
373
+ }
374
+ const r = await opts.call({ op: "file-retry", userid, ids });
375
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
376
+ return;
377
+ }
378
+ // ── File transfer ──────────────────────────────────────────────────
379
+ // Upload: the browser POSTs raw file bytes with ?userid=&name= in the
380
+ // query. We stream them to a temp file (no in-memory buffering of large
381
+ // files) and hand the daemon that path over the existing file-send IPC.
382
+ if (req.method === "POST" && url === "/api/file-send") {
383
+ const q = new URL(req.url || "", "http://x").searchParams;
384
+ const userid = q.get("userid") || "";
385
+ const rawName = q.get("name") || "file";
386
+ const safe = rawName.replace(/[/\\]/g, "_").slice(0, 200) || "file";
387
+ if (!userid) {
388
+ sendJson(res, 400, { ok: false, error: "userid required" });
389
+ return;
390
+ }
391
+ const os = await import("node:os");
392
+ const fs = await import("node:fs");
393
+ const fsp = await import("node:fs/promises");
394
+ const tmpDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
395
+ const tmpPath = join(tmpDir, safe);
396
+ try {
397
+ await new Promise((resolve2, reject) => {
398
+ const ws = fs.createWriteStream(tmpPath);
399
+ req.pipe(ws);
400
+ req.on("error", reject);
401
+ ws.on("error", reject);
402
+ ws.on("finish", () => resolve2());
403
+ });
404
+ const r = await opts.call({ op: "file-send", userid, path: tmpPath });
405
+ if (!r.ok) {
406
+ sendJson(res, 502, r);
407
+ return;
408
+ }
409
+ sendJson(res, 200, { ok: true, ...(r.data ?? {}) });
410
+ }
411
+ catch (e) {
412
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
413
+ }
414
+ finally {
415
+ await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
416
+ }
417
+ return;
418
+ }
419
+ // Persist a file received by browser WebRTC DataChannel. Unlike the
420
+ // ordinary daemon filetransfer path, the bytes arrive in the browser, so
421
+ // the browser uploads the completed Blob here; we save it under
422
+ // downloads/ and ask the daemon to append a normal inbound file chip.
423
+ if (req.method === "POST" && url === "/api/webrtc-file-save") {
424
+ if (!opts.downloadsDir) {
425
+ sendJson(res, 404, { ok: false, error: "downloads disabled" });
426
+ return;
427
+ }
428
+ const q = new URL(req.url || "", "http://x").searchParams;
429
+ const userid = q.get("userid") || "";
430
+ const rawName = q.get("name") || "agentnet-file";
431
+ const safe = rawName.replace(/[/\\]/g, "_").slice(0, 200) || "agentnet-file";
432
+ if (!userid) {
433
+ sendJson(res, 400, { ok: false, error: "userid required" });
434
+ return;
435
+ }
436
+ const fs = await import("node:fs");
437
+ const fsp = await import("node:fs/promises");
438
+ await fsp.mkdir(opts.downloadsDir, { recursive: true });
439
+ let finalName = safe;
440
+ let finalPath = join(opts.downloadsDir, finalName);
441
+ const dot = safe.lastIndexOf(".");
442
+ const base = dot > 0 ? safe.slice(0, dot) : safe;
443
+ const ext = dot > 0 ? safe.slice(dot) : "";
444
+ for (let i = 1; existsSync(finalPath); i++) {
445
+ finalName = `${base}-${i}${ext}`;
446
+ finalPath = join(opts.downloadsDir, finalName);
447
+ }
448
+ try {
449
+ await new Promise((resolve2, reject) => {
450
+ const ws = fs.createWriteStream(finalPath);
451
+ req.pipe(ws);
452
+ req.on("error", reject);
453
+ ws.on("error", reject);
454
+ ws.on("finish", () => resolve2());
455
+ });
456
+ const st = statSync(finalPath);
457
+ const r = await opts.call({ op: "file-log-local", userid, dir: "in", name: finalName, size: st.size });
458
+ if (!r.ok) {
459
+ sendJson(res, 502, r);
460
+ return;
461
+ }
462
+ sendJson(res, 200, { ok: true, name: finalName, size: st.size });
463
+ }
464
+ catch (e) {
465
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
466
+ }
467
+ return;
468
+ }
469
+ // Serve a received file by name, from <configDir>/downloads. Used both for
470
+ // download (?dl=1 → attachment) and inline preview/playback (no dl → the
471
+ // right media content-type + inline disposition + HTTP Range so <video>/
472
+ // <audio> can seek without buffering the whole file).
473
+ // Reveal a received file in the OS file manager. LOCALHOST-ONLY: this
474
+ // opens Finder/Explorer on the machine the DAEMON runs on, so it's
475
+ // meaningful only for someone sitting at that machine — never honour it
476
+ // for a remotely-opened UI (which could otherwise pop a file manager on
477
+ // the host at a stranger's request).
478
+ if (req.method === "POST" && url === "/api/file-reveal") {
479
+ if (!opts.downloadsDir) {
480
+ sendJson(res, 404, { ok: false, error: "downloads disabled" });
481
+ return;
482
+ }
483
+ if (!isLocalRequest(req)) {
484
+ sendJson(res, 403, { ok: false, error: "reveal is available only on this machine" });
485
+ return;
486
+ }
487
+ const body = await readBody(req);
488
+ const name = (typeof body.name === "string" ? body.name : "").replace(/[/\\]/g, "");
489
+ const file = join(opts.downloadsDir, name);
490
+ if (!name || !file.startsWith(opts.downloadsDir) || !existsSync(file)) {
491
+ sendJson(res, 404, { ok: false, error: "not found" });
492
+ return;
493
+ }
494
+ const r = await revealInFileManager(file);
495
+ sendJson(res, r.ok ? 200 : 500, r);
496
+ return;
497
+ }
498
+ if (req.method === "GET" && url === "/api/file-download") {
499
+ if (!opts.downloadsDir) {
500
+ res.writeHead(404);
501
+ res.end("downloads disabled");
502
+ return;
503
+ }
504
+ const q = new URL(req.url || "", "http://x").searchParams;
505
+ const name = (q.get("name") || "").replace(/[/\\]/g, "");
506
+ const file = join(opts.downloadsDir, name);
507
+ // Path-traversal guard: the resolved path must stay under downloadsDir.
508
+ if (!name || !file.startsWith(opts.downloadsDir) || !existsSync(file)) {
509
+ res.writeHead(404);
510
+ res.end("not found");
511
+ return;
512
+ }
513
+ const ext = (name.split(".").pop() || "").toLowerCase();
514
+ const MIME = {
515
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
516
+ webp: "image/webp", svg: "image/svg+xml", heic: "image/heic", bmp: "image/bmp",
517
+ mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", m4v: "video/x-m4v",
518
+ mkv: "video/x-matroska", avi: "video/x-msvideo",
519
+ mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac", wav: "audio/wav",
520
+ ogg: "audio/ogg", flac: "audio/flac", opus: "audio/opus",
521
+ pdf: "application/pdf",
522
+ // Text / markup / code / config → served as UTF-8 text so a plain
523
+ // click opens (previews) them in the browser instead of forcing a
524
+ // download. `.md` and friends are the common case here.
525
+ md: "text/markdown", markdown: "text/markdown",
526
+ txt: "text/plain", text: "text/plain", log: "text/plain",
527
+ json: "application/json", csv: "text/csv", tsv: "text/tab-separated-values",
528
+ xml: "text/xml", yaml: "text/plain", yml: "text/plain",
529
+ ini: "text/plain", conf: "text/plain", cfg: "text/plain", toml: "text/plain",
530
+ html: "text/html", htm: "text/html", css: "text/css",
531
+ js: "text/plain", ts: "text/plain", jsx: "text/plain", tsx: "text/plain",
532
+ py: "text/plain", sh: "text/plain", srt: "text/plain", vtt: "text/vtt",
533
+ };
534
+ const ctype = MIME[ext] || "application/octet-stream";
535
+ const isMedia = ctype.startsWith("image/") || ctype.startsWith("video/") || ctype.startsWith("audio/");
536
+ const isText = ctype.startsWith("text/") || ctype === "application/json" || ctype === "application/pdf";
537
+ // Anything we can render inline (media, text, pdf) opens on a plain GET;
538
+ // `?dl=1` forces a save. Binary stays attachment-only.
539
+ const inlineViewable = isMedia || isText;
540
+ const wantDownload = q.get("dl") === "1" || !inlineViewable;
541
+ // Text needs an explicit charset so the browser renders UTF-8 (Chinese
542
+ // markdown etc.) correctly rather than as mojibake.
543
+ const ctypeHeader = ctype.startsWith("text/") ? `${ctype}; charset=utf-8` : ctype;
544
+ // Content-Disposition must be ASCII — Node throws ERR_INVALID_CHAR on a
545
+ // non-ASCII header value (filenames from macOS contain U+202F before
546
+ // "PM", and others may be Chinese, etc.). Provide an ASCII-only
547
+ // `filename=` fallback plus the RFC 5987 `filename*=UTF-8''…` with the
548
+ // real (percent-encoded) name for clients that support it.
549
+ const asciiName = name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "");
550
+ const disposition = `${wantDownload ? "attachment" : "inline"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`;
551
+ const size = statSync(file).size;
552
+ const range = req.headers.range;
553
+ // Honour a single-range request (what browsers send for media seeking).
554
+ const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
555
+ if (m && !wantDownload) {
556
+ let start = m[1] ? parseInt(m[1], 10) : 0;
557
+ let end = m[2] ? parseInt(m[2], 10) : size - 1;
558
+ if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= size) {
559
+ res.writeHead(416, { "content-range": `bytes */${size}` });
560
+ res.end();
561
+ return;
562
+ }
563
+ res.writeHead(206, {
564
+ "content-type": ctypeHeader,
565
+ "content-disposition": disposition,
566
+ "accept-ranges": "bytes",
567
+ "content-range": `bytes ${start}-${end}/${size}`,
568
+ "content-length": String(end - start + 1),
569
+ });
570
+ createReadStream(file, { start, end }).pipe(res);
571
+ return;
572
+ }
573
+ res.writeHead(200, {
574
+ "content-type": ctypeHeader,
575
+ "content-disposition": disposition,
576
+ "accept-ranges": "bytes",
577
+ "content-length": String(size),
578
+ });
579
+ createReadStream(file).pipe(res);
580
+ return;
581
+ }
582
+ if (req.method === "GET" && url === "/api/state") {
583
+ const [diag, pending] = await Promise.all([
584
+ opts.call({ op: "diag" }),
585
+ opts.call({ op: "friends-pending" }),
586
+ ]);
587
+ const d = diag.ok ? (diag.data ?? {}) : {};
588
+ const identity = d.identity ?? {};
589
+ const tun = d.tun ?? {};
590
+ const me = {
591
+ userid: identity.userid,
592
+ address: identity.address,
593
+ // The configured TUN address is what this host can actually route.
594
+ // Prefer it over a stale init/default allocation during Dora outage.
595
+ ip: tun.ip ?? d.allocatedIp,
596
+ installed: !!tun.ip, // lan/TUN is up
597
+ };
598
+ const friends = d.friends ?? [];
599
+ const pend = pending.ok ? (pending.data?.pending ?? []) : [];
600
+ sendJson(res, 200, { me, friends, pending: pend });
601
+ return;
602
+ }
603
+ // Rich friend list (alias / status / unread / last message). Falls back
604
+ // to the diag-derived list on older daemons that lack the op.
605
+ if (req.method === "GET" && url === "/api/friends-list") {
606
+ const r = await opts.call({ op: "friends-list" });
607
+ if (r.ok) {
608
+ sendJson(res, 200, r.data ?? { friends: [] });
609
+ return;
610
+ }
611
+ const diag = await opts.call({ op: "diag" });
612
+ const friends = diag.ok ? (diag.data?.friends ?? []) : [];
613
+ sendJson(res, 200, { friends });
614
+ return;
615
+ }
616
+ // One bootstrap call for the desktop UI: composes the design's DK_*
617
+ // shapes (me / peers / requests / exits) from diag + friends-list +
618
+ // ipam + routes, so the client data layer is a single poll.
619
+ if (req.method === "GET" && url === "/api/desktop") {
620
+ const [diag, pending, flist] = await Promise.all([
621
+ opts.call({ op: "diag" }),
622
+ opts.call({ op: "friends-pending" }),
623
+ opts.call({ op: "friends-list" }),
624
+ ]);
625
+ const d = (diag.ok ? diag.data : {}) ?? {};
626
+ const identity = d.identity ?? {};
627
+ const tun = d.tun ?? {};
628
+ const node = d.node ?? {};
629
+ const diagFriends = d.friends ?? [];
630
+ const ipamList = d.ipam ?? [];
631
+ const ipByUserid = new Map();
632
+ for (const p of ipamList)
633
+ if (p.carrierId)
634
+ ipByUserid.set(p.carrierId, p.virtualIp);
635
+ const sessByUserid = new Map();
636
+ for (const f of diagFriends) {
637
+ const uid = f.carrierId || f.pubkey || "";
638
+ if (uid && f.session)
639
+ sessByUserid.set(uid, f.session);
640
+ }
641
+ // Is THIS node one of the official exit nodes? (so the UI can badge it).
642
+ const meExit = DEFAULT_EXITS.find((e) => e.userid && e.userid === identity.userid);
643
+ const me = {
644
+ name: node.name || (identity.userid ?? "").slice(0, 8),
645
+ // Handle reads as a network address: <name>@decentnetwork.
646
+ handle: `${node.name || "peer"}@decentnetwork`,
647
+ description: node.statusMessage ?? "",
648
+ userId: identity.userid ?? "",
649
+ carrier: identity.address ?? "",
650
+ netKey: identity.userid ?? "",
651
+ ip: tun.ip ?? d.allocatedIp ?? "",
652
+ online: !!tun.ip,
653
+ lanVer: opts.meExtra?.lanVer ?? "",
654
+ peerVer: opts.meExtra?.peerVer ?? "",
655
+ channel: opts.meExtra?.channel ?? "@next",
656
+ wire: opts.meExtra?.wire ?? "163",
657
+ isExit: !!meExit,
658
+ exitRegion: meExit?.region ?? null,
659
+ };
660
+ const fl = (flist.ok ? (flist.data?.friends ?? []) : []);
661
+ const peers = fl.map((f) => {
662
+ const uid = f.userid ?? "";
663
+ const sess = sessByUserid.get(uid);
664
+ const status = f.status;
665
+ const online = status === "connected" || status === "online";
666
+ const realName = typeof f.name === "string" && f.name !== uid ? f.name : undefined;
667
+ const lm = f.lastMessage;
668
+ return {
669
+ id: uid,
670
+ alias: f.alias || realName || null,
671
+ userId: uid,
672
+ // Shareable Carrier address so a friend can be recommended to
673
+ // another friend (userid only works once already friends).
674
+ address: f.address || "",
675
+ online,
676
+ via: online ? viaFromTransport(sess?.transport) : null,
677
+ ping: null,
678
+ ip: ipByUserid.get(uid) ?? "",
679
+ unread: f.unread ?? 0,
680
+ agent: false,
681
+ lastMsg: lm ? (lm.dir === "out" ? "you: " : "") + (lm.text ?? "") : "",
682
+ lastTime: fmtTime(lm?.ts),
683
+ wire: "163",
684
+ };
685
+ });
686
+ const pend = pending.ok ? (pending.data?.pending ?? []) : [];
687
+ const requests = pend.map((p, i) => ({
688
+ id: p.userid || p.address || `r${i}`,
689
+ carrier: p.address || p.userid || "",
690
+ userid: p.userid || "",
691
+ via: "lan",
692
+ time: "",
693
+ }));
694
+ // Exit nodes = the AVAILABLE exits this node knows about, grouped by
695
+ // region. The authoritative list is the shipped DEFAULT_EXITS
696
+ // (config/default-exits.yaml) — every install has it, so the panel is
697
+ // populated out-of-the-box even on a node with no routes.yaml. Any
698
+ // extra exit IPs an operator added to routes.yaml are folded in too.
699
+ // online status: is that exit a currently-connected friend (by userid,
700
+ // else by its virtual ip showing up as a live peer).
701
+ let routes = { regions: [], default: "direct" };
702
+ if (existsSync(opts.routesPath)) {
703
+ routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
704
+ }
705
+ const onlineUserids = new Set();
706
+ const onlineIps = new Set();
707
+ for (const f of diagFriends) {
708
+ const uid = f.carrierId || f.pubkey || "";
709
+ const s = f.session;
710
+ if (uid && s && s.transport && s.transport !== "none") {
711
+ onlineUserids.add(uid);
712
+ const ip = ipByUserid.get(uid);
713
+ if (ip)
714
+ onlineIps.add(ip);
715
+ }
716
+ }
717
+ const regionMeta = {
718
+ china: { flag: "CN", label: "China" },
719
+ japan: { flag: "JP", label: "Japan" },
720
+ us: { flag: "US", label: "United States" },
721
+ };
722
+ const byRegion = new Map();
723
+ for (const e of DEFAULT_EXITS) {
724
+ if (!e.virtual_ip)
725
+ continue;
726
+ const region = e.region || "other";
727
+ if (!byRegion.has(region))
728
+ byRegion.set(region, []);
729
+ byRegion.get(region).push({
730
+ ip: e.virtual_ip,
731
+ host: e.name,
732
+ online: onlineUserids.has(e.userid) || onlineIps.has(e.virtual_ip),
733
+ reachable: false,
734
+ ping: null,
735
+ });
736
+ }
737
+ // Fold in any extra exit IPs from routes.yaml not already covered.
738
+ const knownIps = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
739
+ for (const r of routes.regions ?? []) {
740
+ for (const ip of r.exits ?? []) {
741
+ if (knownIps.has(ip))
742
+ continue;
743
+ knownIps.add(ip);
744
+ if (!byRegion.has(r.name))
745
+ byRegion.set(r.name, []);
746
+ byRegion.get(r.name).push({ ip, host: ip, online: onlineIps.has(ip), reachable: false, ping: null });
747
+ }
748
+ }
749
+ // REACHABILITY (data plane): presence "online" only means the peer is
750
+ // announcing on the DHT — it does NOT mean IP packets get through (a
751
+ // desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
752
+ // e.g. GFAX). Actively probe each exit's CONNECT-proxy port over the mesh:
753
+ // a completed TCP handshake proves the L3 path + the exit process are both
754
+ // live. Probed in parallel with a short timeout so the panel stays snappy.
755
+ const allNodes = [...byRegion.values()].flat();
756
+ await Promise.all(allNodes.map((n) => new Promise((done) => {
757
+ const t0 = Date.now();
758
+ const sock = net.connect({ host: n.ip, port: EXIT_PROBE_PORT });
759
+ let settled = false;
760
+ const finish = (ok) => {
761
+ if (settled)
762
+ return;
763
+ settled = true;
764
+ clearTimeout(timer);
765
+ sock.destroy();
766
+ n.reachable = ok;
767
+ n.ping = ok ? Date.now() - t0 : null;
768
+ done();
769
+ };
770
+ const timer = setTimeout(() => finish(false), EXIT_PROBE_TIMEOUT_MS);
771
+ sock.once("connect", () => finish(true));
772
+ // ECONNREFUSED means the host answered (L3 reachable) but the proxy
773
+ // isn't listening — still "reachable" for triage; a timeout/no-route is
774
+ // the real "can't connect".
775
+ sock.once("error", (e) => finish(e.code === "ECONNREFUSED"));
776
+ })));
777
+ const exits = [...byRegion.entries()].map(([region, nodes]) => ({
778
+ region,
779
+ flag: regionMeta[region]?.flag ?? region.slice(0, 2).toUpperCase(),
780
+ label: regionMeta[region]?.label ?? region,
781
+ nodes,
782
+ reachableCount: nodes.filter((n) => n.reachable).length,
783
+ unreachableCount: nodes.filter((n) => !n.reachable).length,
784
+ }));
785
+ const activeExit = routes.default && routes.default !== "direct" ? routes.default : null;
786
+ sendJson(res, 200, { me, peers, requests, exits, activeExit });
787
+ return;
788
+ }
789
+ if (req.method === "GET" && url === "/api/chat-history") {
790
+ const peer = new URL(req.url || "/", "http://x").searchParams.get("peer") || undefined;
791
+ const r = await opts.call({ op: "chat-history", userid: peer });
792
+ sendJson(res, 200, r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
793
+ return;
794
+ }
795
+ if (req.method === "POST" && url === "/api/chat-send") {
796
+ const { userid, text } = await readBody(req);
797
+ const r = await opts.call({ op: "chat-send", userid, text });
798
+ sendJson(res, r.ok ? 200 : 400, r);
799
+ return;
800
+ }
801
+ if (req.method === "POST" && url === "/api/chat-log-local") {
802
+ const { userid, dir, text } = await readBody(req);
803
+ const r = await opts.call({ op: "chat-log-local", userid, dir: dir === "out" ? "out" : "in", text });
804
+ sendJson(res, r.ok ? 200 : 400, r);
805
+ return;
806
+ }
807
+ if (req.method === "POST" && url === "/api/chat-mark-read") {
808
+ const { userid } = await readBody(req);
809
+ const r = await opts.call({ op: "chat-mark-read", userid });
810
+ sendJson(res, r.ok ? 200 : 400, r);
811
+ return;
812
+ }
813
+ // WebRTC call signaling (peer-webrtc). The browser CallEngine sends
814
+ // RtcSignals here and long-polls call-poll for inbound ones; the daemon
815
+ // relays both over the Carrier "carrier" invite extension.
816
+ if (req.method === "POST" && url === "/api/call-signal") {
817
+ const { userid, data } = await readBody(req);
818
+ const r = await opts.call({ op: "call-signal", userid, data });
819
+ sendJson(res, r.ok ? 200 : 400, r);
820
+ return;
821
+ }
822
+ if (req.method === "GET" && url === "/api/call-poll") {
823
+ // The daemon holds this up to ~20s (long-poll). opts.call's own IPC
824
+ // timeout is 30s, so it returns before that; on any error, resolve
825
+ // empty so the browser simply re-polls.
826
+ try {
827
+ const r = await opts.call({ op: "call-poll" });
828
+ sendJson(res, 200, r.ok ? r.data ?? { signals: [] } : { signals: [] });
829
+ }
830
+ catch {
831
+ sendJson(res, 200, { signals: [] });
832
+ }
833
+ return;
834
+ }
835
+ if (req.method === "POST" && url === "/api/friend-remove") {
836
+ const { userid } = await readBody(req);
837
+ const r = await opts.call({ op: "friend-remove", userid });
838
+ sendJson(res, r.ok ? 200 : 400, r);
839
+ return;
840
+ }
841
+ if (req.method === "POST" && url === "/api/friend-alias") {
842
+ const { userid, alias } = await readBody(req);
843
+ const r = await opts.call({ op: "friend-set-alias", userid, alias });
844
+ sendJson(res, r.ok ? 200 : 400, r);
845
+ return;
846
+ }
847
+ if (req.method === "POST" && url === "/api/set-profile") {
848
+ const { name, description } = await readBody(req);
849
+ const r = await opts.call({ op: "set-profile", name, description });
850
+ sendJson(res, r.ok ? 200 : 400, r);
851
+ return;
852
+ }
853
+ if (req.method === "GET" && url === "/api/routes") {
854
+ let routes = { regions: [], default: "direct" };
855
+ if (existsSync(opts.routesPath)) {
856
+ routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
857
+ }
858
+ const diag = await opts.call({ op: "diag" });
859
+ const available = diag.ok ? (diag.data?.ipam ?? []) : [];
860
+ sendJson(res, 200, { routes, available });
861
+ return;
862
+ }
863
+ if (req.method === "GET" && url === "/api/dora") {
864
+ let records = [];
865
+ if (opts.doraRosterPath && existsSync(opts.doraRosterPath)) {
866
+ const r = yaml.load(readFileSync(opts.doraRosterPath, "utf-8"));
867
+ records = r?.records ?? [];
868
+ }
869
+ sendJson(res, 200, { isDora: !!opts.doraRosterPath, records });
870
+ return;
871
+ }
872
+ if (req.method === "POST" && url === "/api/routes") {
873
+ const body = await readBody(req);
874
+ try {
875
+ const routes = JSON.parse(body.routes ?? "{}");
876
+ writeFileSync(opts.routesPath, yaml.dump(routes, { lineWidth: -1 }), "utf-8");
877
+ sendJson(res, 200, { ok: true });
878
+ }
879
+ catch (err) {
880
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
881
+ }
882
+ return;
883
+ }
884
+ if (req.method === "POST" && url === "/api/accept") {
885
+ const { userid } = await readBody(req);
886
+ const r = await opts.call({ op: "friends-accept", userid });
887
+ sendJson(res, r.ok ? 200 : 400, r);
888
+ return;
889
+ }
890
+ if (req.method === "POST" && url === "/api/reject") {
891
+ const { userid } = await readBody(req);
892
+ const r = await opts.call({ op: "friends-reject", userid });
893
+ sendJson(res, r.ok ? 200 : 400, r);
894
+ return;
895
+ }
896
+ if (req.method === "POST" && url === "/api/add") {
897
+ const { address } = await readBody(req);
898
+ // A friend-request is fire-and-forget: it's delivered when the peer is
899
+ // online and only becomes a friend once they accept. Don't let the UI
900
+ // hang on the network round-trip (DHT lookup / unreachable peer) — kick
901
+ // the send off, but respond within a short window. A fast failure (e.g.
902
+ // a malformed address) still surfaces its real error; a slow send
903
+ // returns optimistic success and continues in the background.
904
+ const sent = opts
905
+ .call({ op: "friend-request", address })
906
+ .then((r) => ({ done: true, r }))
907
+ .catch((e) => ({ done: true, r: { ok: false, error: e instanceof Error ? e.message : String(e) } }));
908
+ const settled = await Promise.race([
909
+ sent,
910
+ new Promise((r) => setTimeout(() => r({ done: false }), 2500)),
911
+ ]);
912
+ if (settled.done) {
913
+ sendJson(res, settled.r.ok ? 200 : 400, settled.r);
914
+ }
915
+ else {
916
+ void sent.catch(() => { }); // let it finish in the background, no unhandled rejection
917
+ sendJson(res, 200, { ok: true, queued: true });
918
+ }
919
+ return;
920
+ }
921
+ res.writeHead(404);
922
+ res.end("not found");
923
+ }
924
+ catch (err) {
925
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
926
+ }
927
+ };
928
+ const server = http.createServer(handler);
929
+ // `localhost` resolves to BOTH ::1 and 127.0.0.1. Binding only 127.0.0.1
930
+ // left the UI unreachable in a browser: Chrome/Safari try ::1 first and
931
+ // don't fall back the way curl does, so http://localhost:8765 refused the
932
+ // connection while 127.0.0.1:8765 served fine — and the daemon log claimed
933
+ // the UI was up. When the caller asked for loopback (the default), listen on
934
+ // BOTH families. An explicit non-loopback host is honoured as given.
935
+ const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
936
+ let v6 = null;
937
+ // Beagle is a consumer app: a busy port must read as a sentence, not as an
938
+ // unhandled 'error' event and a v8 stack trace. Without this listener Node
939
+ // throws EADDRINUSE out of the event loop and the process dies with 20 lines
940
+ // of internals — which is exactly what a second `beagle` run produced.
941
+ server.on("error", (err) => {
942
+ if (err.code === "EADDRINUSE") {
943
+ log(`Port ${port} is already in use — Beagle may already be running.`);
944
+ log(`Open http://${isLoopback ? "localhost" : host}:${port} , or start this one on another port with --port <n>.`);
945
+ process.exit(1);
946
+ }
947
+ if (err.code === "EACCES") {
948
+ log(`Not allowed to bind port ${port}. Ports below 1024 need elevated privilege — pick a higher one with --port <n>.`);
949
+ process.exit(1);
950
+ }
951
+ log(`Could not start the web server: ${err.message}`);
952
+ process.exit(1);
953
+ });
954
+ server.listen(port, host === "localhost" ? "127.0.0.1" : host, () => {
955
+ log(`Beagle UI on http://${isLoopback ? "localhost" : host}:${port}`);
956
+ });
957
+ if (isLoopback) {
958
+ // Second server object on ::1: Node can't bind one listener to two
959
+ // specific addresses, and binding :: instead would expose the UI on every
960
+ // interface — the opposite of what a loopback default promises.
961
+ v6 = http.createServer(handler);
962
+ v6.on("error", (err) => {
963
+ // A host with IPv6 disabled gives EADDRNOTAVAIL/EAFNOSUPPORT; the IPv4
964
+ // listener above is already serving, so this is not fatal.
965
+ if (err.code !== "EADDRINUSE")
966
+ log(`(IPv6 loopback unavailable: ${err.code ?? err.message} — IPv4 still serving)`);
967
+ });
968
+ v6.listen(port, "::1");
969
+ }
970
+ return {
971
+ stop: () => {
972
+ server.close();
973
+ v6?.close();
974
+ },
975
+ };
976
+ }
977
+ const PAGE = `<!doctype html>
978
+ <html lang="en"><head><meta charset="utf-8"/>
979
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
980
+ <title>Beagle — friends</title>
981
+ <style>
982
+ :root { color-scheme: light dark; }
983
+ body { font: 15px/1.5 -apple-system, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
984
+ h1 { font-size: 1.3rem; } h2 { font-size: 1rem; margin: 1.5rem 0 .5rem; color: #888; text-transform: uppercase; letter-spacing: .04em; }
985
+ .row { display: flex; align-items: center; gap: .75rem; padding: .55rem .7rem; border: 1px solid #8883; border-radius: 8px; margin-bottom: .4rem; }
986
+ .row .meta { flex: 1; min-width: 0; }
987
+ .row .name { font-weight: 600; }
988
+ .row .sub { font-size: .8rem; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
989
+ .dot { width: .6rem; height: .6rem; border-radius: 50%; flex: 0 0 auto; }
990
+ .online { background: #2ecc71; } .offline { background: #bbb; } .requested { background: #f1c40f; }
991
+ button { font: inherit; padding: .3rem .7rem; border-radius: 6px; border: 1px solid #8886; background: transparent; cursor: pointer; }
992
+ button.accept { border-color: #2ecc71; color: #2ecc71; } button.reject { border-color: #e74c3c; color: #e74c3c; }
993
+ button:hover { background: #8881; }
994
+ .add { display: flex; gap: .5rem; margin: .5rem 0 1.5rem; }
995
+ .add input { flex: 1; font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid #8886; background: transparent; color: inherit; }
996
+ .empty { color: #999; font-style: italic; padding: .4rem 0; }
997
+ #toast { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: .5rem 1rem; border-radius: 8px; opacity: 0; transition: opacity .2s; }
998
+ #toast.show { opacity: 1; }
999
+ </style></head>
1000
+ <body>
1001
+ <h1>decentlan · friends</h1>
1002
+ <div id="me" class="sub" style="margin:-.5rem 0 1rem"></div>
1003
+
1004
+ <div class="add">
1005
+ <input id="addr" placeholder="paste a friend's address to send a request" autocomplete="off"/>
1006
+ <button onclick="addFriend()">Add</button>
1007
+ </div>
1008
+
1009
+ <h2>Friend requests <span id="pcount"></span></h2>
1010
+ <div id="pending"></div>
1011
+
1012
+ <div id="doraPanel" style="display:none">
1013
+ <h2>Dora allocations <span id="dcount"></span></h2>
1014
+ <div id="dora"></div>
1015
+ </div>
1016
+
1017
+ <h2>Exit nodes <span id="ecount"></span></h2>
1018
+ <div id="exits"></div>
1019
+ <div class="add">
1020
+ <input id="exitIp" placeholder="exit virtual IP (e.g. 10.86.1.15)" autocomplete="off" style="flex:2"/>
1021
+ <input id="exitRegion" placeholder="region (china/japan/us)" autocomplete="off" list="regionList" style="flex:1"/>
1022
+ <datalist id="regionList"><option>china</option><option>japan</option><option>us</option></datalist>
1023
+ <button onclick="addExit()">Add exit</button>
1024
+ </div>
1025
+
1026
+ <h2>Friends <span id="fcount"></span></h2>
1027
+ <div id="friends"></div>
1028
+
1029
+ <div id="chat" style="display:none; position:fixed; inset:0; background:Canvas; padding:1.5rem 1rem; max-width:760px; margin:0 auto;">
1030
+ <div class="row" style="border:none; padding-left:0">
1031
+ <button onclick="closeChat()">← Back</button>
1032
+ <div class="meta"><div class="name" id="chatName"></div><div class="sub" id="chatSub"></div></div>
1033
+ </div>
1034
+ <div id="chatLog" style="height:calc(100vh - 13rem); overflow-y:auto; display:flex; flex-direction:column; gap:.35rem; padding:.5rem 0;"></div>
1035
+ <div class="add"><input id="chatInput" placeholder="message…" autocomplete="off"/><button onclick="sendChat()">Send</button></div>
1036
+ </div>
1037
+
1038
+ <div id="toast"></div>
1039
+ <script>
1040
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
1041
+ const short = s => { s = String(s ?? ''); return s.length > 20 ? s.slice(0,10)+'…'+s.slice(-6) : s; };
1042
+ let toastT;
1043
+ function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('show'),2200); }
1044
+
1045
+ async function api(path, body){ const r = await fetch(path, body?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}:{}); return r.json(); }
1046
+
1047
+ window.friendsById = {}; window.me = {};
1048
+ async function refresh(){
1049
+ let s, fl;
1050
+ try { [s, fl] = await Promise.all([api('/api/state'), api('/api/friends-list')]); } catch(e){ return; }
1051
+ const pend = s.pending || [], me = s.me || {};
1052
+ const fr = (fl && fl.friends) || s.friends || [];
1053
+ window.me = me;
1054
+ const idStr = me.address || me.userid || '';
1055
+ document.getElementById('me').innerHTML =
1056
+ (me.ip ? ('My IP: <b>' + esc(me.ip) + '</b> · ') : (me.userid ? '' : 'daemon: no identity '))
1057
+ + (me.userid ? ('<span title="'+esc(me.userid)+'">'+esc(short(me.userid))+'</span> ') : '')
1058
+ + (idStr ? '<button onclick="copyId()" style="padding:.1rem .5rem;font-size:.75rem">copy my address</button>' : '')
1059
+ + (me.userid && !me.installed ? ' · <span style="color:#e67e22">lan/TUN not up</span>' : '');
1060
+ fr.forEach(f => { window.friendsById[f.userid||f.carrierId] = f; });
1061
+ document.getElementById('pcount').textContent = pend.length ? '('+pend.length+')' : '';
1062
+ document.getElementById('fcount').textContent = fr.length ? '('+fr.length+')' : '';
1063
+ document.getElementById('pending').innerHTML = pend.length ? pend.map(p => \`
1064
+ <div class="row">
1065
+ <div class="meta"><div class="name">\${esc(p.name || 'unnamed')}</div>
1066
+ <div class="sub">\${esc(short(p.userid))}\${p.hello? ' · "'+esc(p.hello)+'"':''}</div></div>
1067
+ <button class="accept" onclick="act('accept','\${esc(p.userid)}')">Accept</button>
1068
+ <button class="reject" onclick="act('reject','\${esc(p.userid)}')">Reject</button>
1069
+ </div>\`).join('') : '<div class="empty">No pending requests.</div>';
1070
+ document.getElementById('friends').innerHTML = fr.length ? fr.map(f => {
1071
+ const uid = f.userid||f.carrierId;
1072
+ const nm = friendName(f, uid);
1073
+ const lm = f.lastMessage;
1074
+ const preview = lm ? ((lm.dir==='out'?'You: ':'') + lm.text) : (f.status||'offline');
1075
+ const badge = f.unread ? '<span style="background:#e74c3c;color:#fff;border-radius:10px;padding:0 .45rem;font-size:.7rem;margin-left:.4rem">'+f.unread+'</span>' : '';
1076
+ return \`<div class="row" style="cursor:pointer" onclick="openChat('\${esc(uid)}')" title="open chat">
1077
+ \${avatar(uid, nm, f.status)}
1078
+ <div class="meta"><div class="name">\${esc(nm)}\${badge}</div>
1079
+ <div class="sub"><span class="dot \${esc(f.status||'offline')}" style="display:inline-block;vertical-align:middle"></span> \${esc(preview)}</div></div>
1080
+ <button onclick="event.stopPropagation();editAlias('\${esc(uid)}')" title="rename" style="padding:.2rem .55rem">✎</button>
1081
+ <button class="reject" onclick="event.stopPropagation();delFriend('\${esc(uid)}')" title="remove friend" style="padding:.2rem .55rem">×</button>
1082
+ </div>\`;
1083
+ }).join('') : '<div class="empty">No friends yet.</div>';
1084
+ }
1085
+ // The build default "@decentnetwork/peer" is useless (every node sends it) —
1086
+ // treat it as no-name and fall back to alias / short userid.
1087
+ function friendName(f, uid){
1088
+ const n = f.alias || (f.name && f.name !== '@decentnetwork/peer' ? f.name : '');
1089
+ return n || short(uid);
1090
+ }
1091
+ // Deterministic colored-initial avatar from the userid (no protocol/avatar
1092
+ // exchange needed yet — same userid always gets the same color + letter).
1093
+ function avatar(uid, nm, status){
1094
+ const colors=['#e74c3c','#e67e22','#f39c12','#16a085','#27ae60','#2980b9','#8e44ad','#2c3e50','#d35400','#c0392b'];
1095
+ let h=0; for(let i=0;i<String(uid).length;i++) h=(h*31+uid.charCodeAt(i))>>>0;
1096
+ const c=colors[h%colors.length];
1097
+ const ch=((nm||'?').trim().charAt(0)||'?').toUpperCase();
1098
+ return '<span style="display:inline-flex;width:2rem;height:2rem;border-radius:50%;background:'+c+';color:#fff;align-items:center;justify-content:center;font-weight:700;flex:0 0 auto">'+esc(ch)+'</span>';
1099
+ }
1100
+ function copyId(){ const id=(window.me&&(window.me.address||window.me.userid))||''; if(id&&navigator.clipboard){ navigator.clipboard.writeText(id); toast('Your address copied — share it so others can add you'); } }
1101
+ async function delFriend(uid){ if(!confirm('Remove this friend and your messages with them?')) return; const r=await api('/api/friend-remove',{userid:uid}); toast(r.ok?'Removed':(r.error||'failed')); if(chatWith===uid) closeChat(); refresh(); }
1102
+ async function editAlias(uid){ const cur=(window.friendsById[uid]||{}).alias||''; const a=prompt('Local name for this friend (empty to clear):', cur); if(a===null) return; const r=await api('/api/friend-alias',{userid:uid,alias:a}); toast(r.ok?'Saved':(r.error||'failed')); refresh(); }
1103
+ async function act(kind, userid){ const r = await api('/api/'+kind, {userid}); toast(r.ok? (kind==='accept'?'Accepted':'Rejected') : (r.error||'failed')); refresh(); }
1104
+ async function addFriend(){ const a=document.getElementById('addr'); const v=a.value.trim(); if(!v) return; const r=await api('/api/add',{address:v}); toast(r.ok?'Friend-request sent':(r.error||'failed')); if(r.ok) a.value=''; refresh(); }
1105
+ document.getElementById('addr').addEventListener('keydown', e=>{ if(e.key==='Enter') addFriend(); });
1106
+
1107
+ let chatWith = null, chatTimer = null;
1108
+ async function openChat(userid){
1109
+ chatWith = userid;
1110
+ const f = window.friendsById[userid] || {};
1111
+ document.getElementById('chatName').textContent = friendName(f, userid);
1112
+ document.getElementById('chatSub').textContent = (f.status||'') + ' · ' + short(userid);
1113
+ document.getElementById('chat').style.display = 'block';
1114
+ api('/api/chat-mark-read', {userid}); // clears the unread badge
1115
+ await renderChat();
1116
+ clearInterval(chatTimer); chatTimer = setInterval(renderChat, 2000);
1117
+ document.getElementById('chatInput').focus();
1118
+ }
1119
+ function closeChat(){ chatWith = null; clearInterval(chatTimer); document.getElementById('chat').style.display='none'; refresh(); }
1120
+ async function renderChat(){
1121
+ if(!chatWith) return;
1122
+ let h; try { h = await api('/api/chat-history?peer='+encodeURIComponent(chatWith)); } catch(e){ return; }
1123
+ const msgs = (h.chats && h.chats[chatWith]) || [];
1124
+ const log = document.getElementById('chatLog');
1125
+ log.innerHTML = msgs.length ? msgs.map(m => \`<div style="align-self:\${m.dir==='out'?'flex-end':'flex-start'};max-width:75%;padding:.4rem .7rem;border-radius:12px;background:\${m.dir==='out'?'#3478f6':'#8883'};color:\${m.dir==='out'?'#fff':'inherit'}">\${esc(m.text)}</div>\`).join('') : '<div class="empty">No messages yet — say hi.</div>';
1126
+ log.scrollTop = log.scrollHeight;
1127
+ }
1128
+ async function sendChat(){
1129
+ const i = document.getElementById('chatInput'); const t = i.value.trim();
1130
+ if(!t || !chatWith) return;
1131
+ i.value='';
1132
+ const r = await api('/api/chat-send', {userid: chatWith, text: t});
1133
+ if(!r.ok) toast(r.error||'send failed');
1134
+ renderChat();
1135
+ }
1136
+ document.getElementById('chatInput').addEventListener('keydown', e=>{ if(e.key==='Enter') sendChat(); });
1137
+
1138
+ // ---- Exit nodes (routes.yaml) ----
1139
+ let routesObj = { regions: [], default: 'direct' };
1140
+ async function refreshExits(){
1141
+ let r; try { r = await api('/api/routes'); } catch(e){ return; }
1142
+ routesObj = r.routes || { regions: [], default: 'direct' };
1143
+ if(!Array.isArray(routesObj.regions)) routesObj.regions = [];
1144
+ const regions = routesObj.regions;
1145
+ const total = regions.reduce((n,rg)=>n+((rg.exits||[]).length),0);
1146
+ document.getElementById('ecount').textContent = total ? '('+total+')' : '';
1147
+ document.getElementById('exits').innerHTML = regions.length ? regions.map((rg)=> \`
1148
+ <div class="row" style="flex-direction:column; align-items:stretch; gap:.3rem">
1149
+ <div class="name">\${esc(rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
1150
+ \${(rg.exits||[]).map(ip=>\`<div class="sub" style="display:flex; gap:.5rem; align-items:center"><span style="flex:1">\${esc(ip)}</span><button class="reject" onclick="removeExit('\${esc(rg.name)}','\${esc(ip)}')">×</button></div>\`).join('')}
1151
+ </div>\`).join('') : '<div class="empty">No routes.yaml regions — using auto-discovery.</div>';
1152
+ }
1153
+ function saveRoutes(){ return api('/api/routes', { routes: JSON.stringify(routesObj) }); }
1154
+ async function addExit(){
1155
+ const ip=document.getElementById('exitIp').value.trim(), region=(document.getElementById('exitRegion').value.trim()||'china');
1156
+ if(!ip) return;
1157
+ let rg = routesObj.regions.find(x=>x.name===region);
1158
+ if(!rg){ rg={name:region, exits:[]}; routesObj.regions.push(rg); }
1159
+ rg.exits = rg.exits||[]; if(!rg.exits.includes(ip)) rg.exits.push(ip);
1160
+ const r = await saveRoutes(); toast(r.ok?'Added — restart the router to apply':(r.error||'failed'));
1161
+ document.getElementById('exitIp').value=''; refreshExits();
1162
+ }
1163
+ async function removeExit(region, ip){
1164
+ const rg = routesObj.regions.find(x=>x.name===region); if(!rg) return;
1165
+ rg.exits = (rg.exits||[]).filter(x=>x!==ip);
1166
+ const r = await saveRoutes(); toast(r.ok?'Removed — restart the router to apply':(r.error||'failed')); refreshExits();
1167
+ }
1168
+
1169
+ // ---- Dora allocations (if this node runs a dora server) ----
1170
+ async function refreshDora(){
1171
+ let d; try { d = await api('/api/dora'); } catch(e){ return; }
1172
+ const panel = document.getElementById('doraPanel');
1173
+ if(!d.isDora){ panel.style.display='none'; return; }
1174
+ panel.style.display='block';
1175
+ const recs = d.records || [];
1176
+ document.getElementById('dcount').textContent = '('+recs.length+')';
1177
+ document.getElementById('dora').innerHTML = recs.length ? recs.map(r => \`
1178
+ <div class="row">
1179
+ <div class="meta"><div class="name">\${esc(r.name||'unnamed')} · \${esc(r.virtualIp||'')}</div>
1180
+ <div class="sub">\${esc(short(r.userid))}</div></div>
1181
+ </div>\`).join('') : '<div class="empty">No nodes registered yet.</div>';
1182
+ }
1183
+
1184
+ refresh(); refreshExits(); refreshDora();
1185
+ setInterval(refresh, 3000); setInterval(refreshExits, 8000); setInterval(refreshDora, 5000);
1186
+ </script>
1187
+ </body></html>`;