@decentnetwork/lan 0.1.105 → 0.1.107

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.
Binary file
Binary file
Binary file
Binary file
@@ -53,6 +53,10 @@ export declare class PeerManager extends EventEmitter {
53
53
  name?: string;
54
54
  description?: string;
55
55
  }): void;
56
+ /** Sign a message with this identity's private key (XEdDSA over the X25519
57
+ * Carrier key). The signature verifies against the userid. Used by "Sign in
58
+ * with Decent" to prove key possession to a website. Returns 64-byte sig. */
59
+ sign(message: Uint8Array): Uint8Array;
56
60
  /** Offer a file to a friend (toxcore-standard transfer). Returns the fileId. */
57
61
  sendFile(userid: string, data: Uint8Array, name: string): string | null;
58
62
  /** Accept an incoming file offer. */
@@ -106,6 +106,14 @@ export class PeerManager extends EventEmitter {
106
106
  this.peer.setUserInfo(info);
107
107
  this.logger.info(`Profile updated (name="${info.name ?? "(unchanged)"}")`);
108
108
  }
109
+ /** Sign a message with this identity's private key (XEdDSA over the X25519
110
+ * Carrier key). The signature verifies against the userid. Used by "Sign in
111
+ * with Decent" to prove key possession to a website. Returns 64-byte sig. */
112
+ sign(message) {
113
+ if (!this.peer)
114
+ throw new Error("Peer not created. Call create() first.");
115
+ return this.peer.sign(message);
116
+ }
109
117
  /** Offer a file to a friend (toxcore-standard transfer). Returns the fileId. */
110
118
  sendFile(userid, data, name) {
111
119
  if (!this.peer)
@@ -56,6 +56,14 @@ export interface IpcHandlers {
56
56
  name?: string;
57
57
  description?: string;
58
58
  }) => Promise<void>;
59
+ /** Sign `text` (UTF-8) with this node's Carrier identity key and return the
60
+ * detached signature (hex) plus our userid. Backs "Sign in with Decent":
61
+ * the UI server's local-only /connect flow calls this on Approve. Reachable
62
+ * ONLY over this Unix socket — never exposed on a TCP interface. */
63
+ sign: (text: string) => Promise<{
64
+ sig: string;
65
+ userid: string;
66
+ }>;
59
67
  /** Subscribe to daemon push events (chat / presence / friend-request). The
60
68
  * IPC server keeps the connection open and calls `emit` for each event;
61
69
  * the returned function unsubscribes (called on socket close). Optional —
@@ -82,7 +90,7 @@ export interface IpcHandlers {
82
90
  selfRestart: () => Promise<Record<string, unknown>>;
83
91
  }
84
92
  export interface IpcRequest {
85
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "proxy-reload" | "self-restart";
93
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "self-restart";
86
94
  address?: string;
87
95
  hello?: string;
88
96
  userid?: string;
@@ -221,6 +221,11 @@ export class IpcServer {
221
221
  await this.handlers.chatMarkRead(req.userid, req.ts);
222
222
  return;
223
223
  }
224
+ case "sign": {
225
+ if (typeof req.text !== "string")
226
+ throw new Error("text is required");
227
+ return await this.handlers.sign(req.text);
228
+ }
224
229
  case "proxy-reload":
225
230
  return await this.handlers.proxyReload();
226
231
  case "self-restart":
@@ -320,6 +320,13 @@ export class DaemonServer {
320
320
  this.ipcEvents.on("event", onEvent);
321
321
  return () => this.ipcEvents.off("event", onEvent);
322
322
  },
323
+ sign: async (text) => {
324
+ const sig = this.peerManager.sign(new TextEncoder().encode(text));
325
+ return {
326
+ sig: Buffer.from(sig).toString("hex"),
327
+ userid: this.peerManager.getIdentity().userid,
328
+ };
329
+ },
323
330
  proxyReload: async () => {
324
331
  // Re-read the proxy allowlist from disk and push it into the
325
332
  // running proxy without a daemon restart (which would drop
@@ -119,16 +119,26 @@ export class DoraIntegration {
119
119
  record = await this.tryRegister(myUserid, myAddress, this.opts.preferredIp);
120
120
  }
121
121
  catch (err) {
122
- // Common case: the preferred IP from config.yaml collides
123
- // with another peer's reservation (every fresh `agentnet
124
- // init` defaults to 10.86.1.10, so the second peer to
125
- // register always loses the race). Retry once without
126
- // requestedIp so dora picks any free slot. Errors that
127
- // aren't IP-collision flavored fall through to the outer
128
- // catch and trigger the fallback path.
122
+ // Two recoverable cases, both fixed by retrying without a preferred
123
+ // IP so dora assigns any free in-segment slot:
124
+ // 1. Collision the preferred IP from config.yaml is already taken
125
+ // (every fresh `agentnet init` defaults to 10.86.1.10, so the
126
+ // second peer to register loses the race).
127
+ // 2. Out-of-range the preferred IP belongs to a federated
128
+ // registry's segment whose dora is currently DOWN, so only
129
+ // sibling registries answered and they reject the IP. The dora
130
+ // client already walks siblings to find the owning registry when
131
+ // it IS up (keeping our stable IP); this branch only triggers
132
+ // when the owner is unreachable. Without it we'd fall back to the
133
+ // UNREGISTERED config IP and drop every packet to peers we never
134
+ // learned from the roster (the observed CCTV outage). A temp
135
+ // in-segment IP that's actually registered beats a stable IP
136
+ // that isn't.
137
+ // Anything else (e.g. malformed request) falls through to the outer
138
+ // catch and the config-IP fallback.
129
139
  const msg = err instanceof Error ? err.message : String(err);
130
- const looksLikeIpCollision = /held by|in use|already taken|already in use/i.test(msg);
131
- if (!looksLikeIpCollision)
140
+ const retryWithoutPreferredIp = /held by|in use|already taken|already in use|out of range/i.test(msg);
141
+ if (!retryWithoutPreferredIp)
132
142
  throw err;
133
143
  this.logger.warn(`Preferred IP ${this.opts.preferredIp} not available (${msg}) — requesting any free IP`);
134
144
  record = await this.tryRegister(myUserid, myAddress);
package/dist/ui/server.js CHANGED
@@ -21,6 +21,115 @@ import { DEFAULT_EXITS } from "../config/loader.js";
21
21
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
22
22
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
23
23
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
24
+ /** True only when the request came from the local machine. Used to gate the
25
+ * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
26
+ * identity signing to other hosts. The popup always runs in the local user's
27
+ * browser, so it connects over the loopback interface. */
28
+ function isLocalRequest(req) {
29
+ const a = req.socket.remoteAddress ?? "";
30
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
31
+ }
32
+ /** Validate and normalize a website origin (scheme://host[:port], no path).
33
+ * Returns the canonical origin string or null if malformed. Only http/https
34
+ * are accepted. */
35
+ function validateOrigin(raw) {
36
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 256)
37
+ return null;
38
+ try {
39
+ const u = new URL(raw);
40
+ if (u.protocol !== "http:" && u.protocol !== "https:")
41
+ return null;
42
+ // u.origin drops any path/query/hash and lowercases the host.
43
+ if (u.origin === "null" || !u.origin)
44
+ return null;
45
+ return u.origin;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ // The "Sign in with Decent" consent popup. Self-contained (no external assets),
52
+ // dark theme matching the desktop UI. Reads origin+nonce from its own query,
53
+ // shows the requesting site and this node's identity, and on Approve calls the
54
+ // local /api/connect-approve to sign, then postMessages the result to the
55
+ // opener at the exact requesting origin. Mandatory explicit consent — never
56
+ // returns identity without an Approve click.
57
+ const CONNECT_PAGE = `<!doctype html>
58
+ <html lang="en"><head><meta charset="utf-8">
59
+ <meta name="viewport" content="width=device-width,initial-scale=1">
60
+ <title>Sign in with Decent</title>
61
+ <style>
62
+ :root{--bg:#0c0d11;--panel:#14161d;--line:#262a35;--text:#e7e9ef;--faint:#8b91a3;
63
+ --accent:#5b8cff;--good:#46a758;--bad:#e5604d;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
64
+ *{box-sizing:border-box}
65
+ html,body{margin:0;height:100%}
66
+ body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
67
+ display:flex;align-items:center;justify-content:center;padding:18px}
68
+ .card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--line);
69
+ border-radius:14px;padding:22px 20px}
70
+ .brand{font-weight:600;letter-spacing:.2px;color:var(--accent);font-size:13px;display:flex;align-items:center;gap:7px}
71
+ .brand .d{width:11px;height:11px;background:var(--accent);transform:rotate(45deg);border-radius:2px}
72
+ h1{font-size:18px;margin:14px 0 6px}
73
+ .sub{color:var(--faint);margin:0 0 16px}
74
+ .origin{color:var(--text);font-weight:600;word-break:break-all}
75
+ .id{background:#0e1016;border:1px solid var(--line);border-radius:10px;padding:11px 12px;margin-bottom:14px}
76
+ .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--faint);margin-bottom:4px}
77
+ .userid{font-family:var(--mono);font-size:12.5px;color:var(--text);word-break:break-all}
78
+ .err{background:rgba(229,96,77,.12);border:1px solid rgba(229,96,77,.4);color:#ffb4a8;
79
+ border-radius:9px;padding:9px 11px;font-size:12.5px;margin-bottom:13px}
80
+ .row{display:flex;gap:10px;margin-top:4px}
81
+ .btn{flex:1;border:0;border-radius:9px;padding:11px;font-size:14px;font-weight:600;cursor:pointer}
82
+ .ghost{background:transparent;border:1px solid var(--line);color:var(--text)}
83
+ .ghost:hover{border-color:#3a4050}
84
+ .solid{background:var(--accent);color:#fff}
85
+ .solid:disabled{opacity:.45;cursor:not-allowed}
86
+ .note{color:var(--faint);font-size:11.5px;margin:15px 0 0;line-height:1.45}
87
+ </style></head>
88
+ <body>
89
+ <div class="card">
90
+ <div class="brand"><span class="d"></span>Decent</div>
91
+ <h1>Sign in</h1>
92
+ <p class="sub"><span id="origin" class="origin">…</span> wants to sign you in with your Decent identity.</p>
93
+ <div class="id"><div class="lbl">Your identity</div><div id="userid" class="userid">loading…</div></div>
94
+ <div id="err" class="err" hidden></div>
95
+ <div class="row">
96
+ <button id="deny" class="btn ghost">Deny</button>
97
+ <button id="approve" class="btn solid" disabled>Approve</button>
98
+ </div>
99
+ <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>
100
+ </div>
101
+ <script>
102
+ (function(){
103
+ var qs=new URLSearchParams(location.search);
104
+ var origin=qs.get("origin")||"", nonce=qs.get("nonce")||"";
105
+ var oEl=document.getElementById("origin"), errEl=document.getElementById("err");
106
+ var approve=document.getElementById("approve"), deny=document.getElementById("deny");
107
+ oEl.textContent=origin||"(unknown site)";
108
+ function fail(m){errEl.textContent=m;errEl.hidden=false;}
109
+ function reply(data){ if(window.opener&&validOrigin){ window.opener.postMessage(Object.assign({type:"decent-auth",nonce:nonce},data),origin);} }
110
+ var validOrigin=false;
111
+ try{var u=new URL(origin); validOrigin=(u.protocol==="http:"||u.protocol==="https:")&&u.origin===origin;}catch(e){}
112
+ if(!validOrigin) fail("This sign-in request has an invalid origin and was blocked.");
113
+ if(nonce.length===0||nonce.length>512){ validOrigin=false; fail("This sign-in request is missing a valid nonce."); }
114
+ fetch("/api/state").then(function(r){return r.json();}).then(function(s){
115
+ var uid=(s.me&&s.me.userid)||"";
116
+ document.getElementById("userid").textContent=uid||"(no identity)";
117
+ if(uid&&validOrigin) approve.disabled=false;
118
+ else if(!uid) fail("No local Decent identity found — is the daemon running?");
119
+ }).catch(function(){ fail("Could not reach the local agentnet daemon."); });
120
+ deny.onclick=function(){ reply({error:"denied"}); setTimeout(function(){window.close();},60); };
121
+ approve.onclick=function(){
122
+ approve.disabled=true; approve.textContent="Signing…";
123
+ fetch("/api/connect-approve",{method:"POST",headers:{"content-type":"application/json"},
124
+ body:JSON.stringify({origin:origin,nonce:nonce})})
125
+ .then(function(r){return r.json();})
126
+ .then(function(j){ if(!j.ok) throw new Error(j.error||"sign failed");
127
+ reply({userid:j.userid,sig:j.sig}); setTimeout(function(){window.close();},60); })
128
+ .catch(function(e){ approve.disabled=false; approve.textContent="Approve"; fail("Signing failed: "+e.message); });
129
+ };
130
+ })();
131
+ </script>
132
+ </body></html>`;
24
133
  // ---- shapes the desktop UI (src/ui/desktop) consumes (DK_* in the design) ----
25
134
  const fmtTime = (ts) => {
26
135
  if (!ts)
@@ -94,6 +203,53 @@ export function startFriendUi(opts) {
94
203
  res.end("not found");
95
204
  return;
96
205
  }
206
+ // ── Sign in with Decent ────────────────────────────────────────────
207
+ // A website opens http://localhost:8765/connect?origin=…&nonce=… in a
208
+ // popup; on Approve we sign "decent-auth\n<origin>\n<nonce>" with this
209
+ // node's Carrier identity and postMessage {userid,nonce,sig} back to the
210
+ // site, which verifies the signature against the userid. BOTH routes are
211
+ // LOCALHOST-ONLY: the popup always runs in the local user's browser
212
+ // (localhost), so binding the UI to a LAN IP must never let a remote
213
+ // host request signatures with this identity.
214
+ if (url === "/connect" || url === "/api/connect-approve") {
215
+ if (!isLocalRequest(req)) {
216
+ res.writeHead(403, { "content-type": "text/plain" });
217
+ res.end("Sign in with Decent is available only on this machine (localhost).");
218
+ return;
219
+ }
220
+ }
221
+ if (req.method === "GET" && url === "/connect") {
222
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
223
+ res.end(CONNECT_PAGE);
224
+ return;
225
+ }
226
+ if (req.method === "POST" && url === "/api/connect-approve") {
227
+ const body = await readBody(req);
228
+ const origin = validateOrigin(body.origin);
229
+ const nonce = typeof body.nonce === "string" ? body.nonce : "";
230
+ if (!origin) {
231
+ sendJson(res, 400, { ok: false, error: "invalid origin" });
232
+ return;
233
+ }
234
+ if (!nonce || nonce.length > 512) {
235
+ sendJson(res, 400, { ok: false, error: "invalid nonce" });
236
+ return;
237
+ }
238
+ // Bind BOTH the origin and the nonce into the signed message so a
239
+ // signature minted for site A can't be replayed at site B.
240
+ const message = `decent-auth\n${origin}\n${nonce}`;
241
+ const r = await opts.call({ op: "sign", text: message });
242
+ if (!r.ok) {
243
+ sendJson(res, 502, { ok: false, error: r.error || "sign failed" });
244
+ return;
245
+ }
246
+ sendJson(res, 200, {
247
+ ok: true,
248
+ userid: r.data?.userid ?? "",
249
+ sig: r.data?.sig ?? "",
250
+ });
251
+ return;
252
+ }
97
253
  if (req.method === "GET" && url === "/api/state") {
98
254
  const [diag, pending] = await Promise.all([
99
255
  opts.call({ op: "diag" }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.105",
3
+ "version": "0.1.107",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -78,8 +78,8 @@
78
78
  "build:console": "node scripts/build-console.mjs"
79
79
  },
80
80
  "dependencies": {
81
- "@decentnetwork/dora": "^0.1.6",
82
- "@decentnetwork/peer": "^0.1.46",
81
+ "@decentnetwork/dora": "^0.1.11",
82
+ "@decentnetwork/peer": "^0.1.47",
83
83
  "ink": "^5.2.1",
84
84
  "js-yaml": "^4.1.0",
85
85
  "react": "^18.3.1",