@decentnetwork/lan 0.1.76 → 0.1.78

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
@@ -214,6 +214,30 @@ export declare function cmdProxyUse(args: {
214
214
  peer: string;
215
215
  configDir?: string;
216
216
  }): Promise<void>;
217
+ /**
218
+ * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
219
+ * load-balances / fails over across several exit nodes, sending only China
220
+ * traffic through them and everything else direct. This is the client-side
221
+ * counterpart to `agentnet proxy enable` (which is the per-exit server).
222
+ *
223
+ * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
224
+ * If none are given we auto-discover them from the daemon's live IPAM (every
225
+ * known peer becomes a candidate exit on its proxy port; health checks drop
226
+ * the ones that aren't actually serving a proxy).
227
+ */
228
+ /**
229
+ * Start the local Friend UI — a web page to view friends, accept/reject
230
+ * incoming friend-requests, and add friends by address. Talks to the running
231
+ * daemon over IPC. For manual accept/reject the daemon must run with
232
+ * `friends.autoAccept: false` (otherwise requests are accepted instantly and
233
+ * never queue).
234
+ */
235
+ export declare function cmdUi(args: {
236
+ port?: number;
237
+ listen?: string;
238
+ doraDir?: string;
239
+ configDir?: string;
240
+ }): Promise<void>;
217
241
  export declare function cmdProxyRouter(args: {
218
242
  exit?: string[];
219
243
  port?: number;
@@ -13,6 +13,7 @@ import { AclEngine } from "../acl/acl-engine.js";
13
13
  import { AuditLog } from "../acl/audit.js";
14
14
  import yaml from "js-yaml";
15
15
  import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
16
+ import { startFriendUi } from "../ui/server.js";
16
17
  /**
17
18
  * Refuse to open a second Carrier peer with this identity if the
18
19
  * daemon is already running with the same keypair. Two peers sharing
@@ -1030,6 +1031,48 @@ export async function cmdProxyUse(args) {
1030
1031
  console.log(`# agentnet proxy enable`);
1031
1032
  console.log(`# agentnet grant --peer <your-userid> --tcp ${port}`);
1032
1033
  }
1034
+ /**
1035
+ * Run the multi-exit client router: a local HTTPS (CONNECT) proxy that
1036
+ * load-balances / fails over across several exit nodes, sending only China
1037
+ * traffic through them and everything else direct. This is the client-side
1038
+ * counterpart to `agentnet proxy enable` (which is the per-exit server).
1039
+ *
1040
+ * Exits come from --exit (repeatable, "host[:port]" or "name=host[:port]").
1041
+ * If none are given we auto-discover them from the daemon's live IPAM (every
1042
+ * known peer becomes a candidate exit on its proxy port; health checks drop
1043
+ * the ones that aren't actually serving a proxy).
1044
+ */
1045
+ /**
1046
+ * Start the local Friend UI — a web page to view friends, accept/reject
1047
+ * incoming friend-requests, and add friends by address. Talks to the running
1048
+ * daemon over IPC. For manual accept/reject the daemon must run with
1049
+ * `friends.autoAccept: false` (otherwise requests are accepted instantly and
1050
+ * never queue).
1051
+ */
1052
+ export async function cmdUi(args) {
1053
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1054
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
1055
+ if (daemonPid(config) === null) {
1056
+ console.error("Daemon is not running — start it with 'agentnet up' first.");
1057
+ process.exit(1);
1058
+ }
1059
+ // If this node also runs a dora server, surface its allocation table.
1060
+ // Use --dora-dir, else auto-detect the common locations.
1061
+ const { homedir } = await import("os");
1062
+ const doraCandidates = args.doraDir
1063
+ ? [resolve(args.doraDir, "roster.yaml")]
1064
+ : [resolve(homedir(), ".dora-test", "roster.yaml"), resolve(homedir(), ".decent-registry", "roster.yaml")];
1065
+ const doraRosterPath = doraCandidates.find((p) => existsSync(p)) ?? (args.doraDir ? doraCandidates[0] : undefined);
1066
+ startFriendUi({
1067
+ call: (req) => ipcCall(config, req),
1068
+ routesPath: resolve(dir, "routes.yaml"),
1069
+ doraRosterPath,
1070
+ listenHost: args.listen ?? "127.0.0.1",
1071
+ listenPort: args.port ?? 8765,
1072
+ });
1073
+ // Keep the process alive; runs until Ctrl-C / signal.
1074
+ await new Promise(() => { });
1075
+ }
1033
1076
  export async function cmdProxyRouter(args) {
1034
1077
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
1035
1078
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
10
10
  // Belt-and-braces — also raise it here in case the CLI is run directly
11
11
  // (e.g. `node dist/cli/index.js` rather than via dist/index.js).
12
12
  EventEmitter.defaultMaxListeners = 100;
13
- import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -124,6 +124,13 @@ async function main() {
124
124
  })
125
125
  .demandCommand(1, "Specify a service subcommand (run 'agentnet service --help')"), () => {
126
126
  // parent handler — never invoked because demandCommand above
127
+ })
128
+ .command("ui", "Start the local Friend UI web page (friends list, accept/reject requests, add friend)", (y) => y
129
+ .option("port", { type: "number", default: 8765, describe: "Local listen port" })
130
+ .option("listen", { type: "string", default: "127.0.0.1", describe: "Listen host" })
131
+ .option("dora-dir", { type: "string", describe: "If this node runs a dora server, its data-dir (to show allocations)" })
132
+ .option("config-dir", { type: "string" }), async (argv) => {
133
+ await cmdUi({ port: argv.port, listen: argv.listen, doraDir: argv["dora-dir"], configDir: argv["config-dir"] });
127
134
  })
128
135
  // Tell the running daemon to re-exec itself with its original argv.
129
136
  // The daemon inherits its own uid (root if it was launched as root)
@@ -39,6 +39,10 @@ export interface IpcHandlers {
39
39
  /** Reject (drop) a queued friend-request by userid. Doesn't
40
40
  * notify the sender — they'll just see no acceptance. */
41
41
  friendsReject: (userid: string) => Promise<void>;
42
+ /** Send a chat message (Carrier text, packet 64) to a friend and log it. */
43
+ chatSend: (userid: string, text: string) => Promise<void>;
44
+ /** Return recent chat history grouped by friend userid. */
45
+ chatHistory: () => Promise<Record<string, unknown>>;
42
46
  /** Re-read proxy allowlist from config and apply it to the running
43
47
  * proxy WITHOUT restarting the daemon. Lets `agentnet proxy
44
48
  * allow-host` take effect instantly instead of forcing a daemon
@@ -55,10 +59,11 @@ export interface IpcHandlers {
55
59
  selfRestart: () => Promise<Record<string, unknown>>;
56
60
  }
57
61
  export interface IpcRequest {
58
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "proxy-reload" | "self-restart";
62
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "proxy-reload" | "self-restart";
59
63
  address?: string;
60
64
  hello?: string;
61
65
  userid?: string;
66
+ text?: string;
62
67
  }
63
68
  export interface IpcResponseOk {
64
69
  ok: true;
@@ -144,6 +144,14 @@ export class IpcServer {
144
144
  await this.handlers.friendsReject(req.userid);
145
145
  return;
146
146
  }
147
+ case "chat-send": {
148
+ if (!req.userid)
149
+ throw new Error("userid is required");
150
+ await this.handlers.chatSend(req.userid, req.text ?? "");
151
+ return;
152
+ }
153
+ case "chat-history":
154
+ return await this.handlers.chatHistory();
147
155
  case "proxy-reload":
148
156
  return await this.handlers.proxyReload();
149
157
  case "self-restart":
@@ -33,6 +33,9 @@ export declare class DaemonServer {
33
33
  private ipcServer?;
34
34
  private dnsServer?;
35
35
  private pendingFriends?;
36
+ /** In-memory chat history, keyed by friend userid (last 200 each). Drives
37
+ * the `agentnet ui` chat window; not persisted across restarts. */
38
+ private chatLog;
36
39
  private startedAt;
37
40
  private isRunning;
38
41
  private statusTimer?;
@@ -51,6 +54,8 @@ export declare class DaemonServer {
51
54
  */
52
55
  private startPeerStatusSummary;
53
56
  getStatus(): DaemonStatus;
57
+ /** Append a chat message to the in-memory log (capped at 200 per friend). */
58
+ private logChat;
54
59
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
55
60
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
56
61
  private reFriendAt;
@@ -50,6 +50,9 @@ export class DaemonServer {
50
50
  ipcServer;
51
51
  dnsServer;
52
52
  pendingFriends;
53
+ /** In-memory chat history, keyed by friend userid (last 200 each). Drives
54
+ * the `agentnet ui` chat window; not persisted across restarts. */
55
+ chatLog = new Map();
53
56
  startedAt = 0;
54
57
  isRunning = false;
55
58
  statusTimer;
@@ -167,6 +170,18 @@ export class DaemonServer {
167
170
  throw new Error(`No pending friend-request for userid ${userid}`);
168
171
  // No-op at the Carrier level — sender just sees no acceptance.
169
172
  },
173
+ chatSend: async (userid, text) => {
174
+ if (!text)
175
+ return;
176
+ await this.peerManager.sendText(userid, text);
177
+ this.logChat(userid, "out", text);
178
+ },
179
+ chatHistory: async () => {
180
+ const chats = {};
181
+ for (const [userid, msgs] of this.chatLog)
182
+ chats[userid] = msgs;
183
+ return { chats };
184
+ },
170
185
  proxyReload: async () => {
171
186
  // Re-read the proxy allowlist from disk and push it into the
172
187
  // running proxy without a daemon restart (which would drop
@@ -429,6 +444,10 @@ export class DaemonServer {
429
444
  }
430
445
  return input;
431
446
  };
447
+ // Chat: log incoming Carrier text messages (packet 64) for the UI.
448
+ this.peerManager.on("message", (pubkey, text) => {
449
+ this.logChat(pubkey, "in", text);
450
+ });
432
451
  this.peerManager.on("friend-request", (req) => {
433
452
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
434
453
  const who = `${req.name || "(unnamed)"} ${userid}`;
@@ -649,6 +668,17 @@ export class DaemonServer {
649
668
  activeSessions: this.packetRouter?.getStats().activeSessions || 0,
650
669
  };
651
670
  }
671
+ /** Append a chat message to the in-memory log (capped at 200 per friend). */
672
+ logChat(userid, dir, text) {
673
+ let msgs = this.chatLog.get(userid);
674
+ if (!msgs) {
675
+ msgs = [];
676
+ this.chatLog.set(userid, msgs);
677
+ }
678
+ msgs.push({ dir, text, ts: Date.now() });
679
+ if (msgs.length > 200)
680
+ msgs.splice(0, msgs.length - 200);
681
+ }
652
682
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
653
683
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
654
684
  reFriendAt = new Map();
@@ -126,11 +126,15 @@ export class ConnectProxy {
126
126
  }
127
127
  let bytes = 0;
128
128
  let closed = false;
129
+ let lastBytes = -1;
130
+ let idleTimer;
129
131
  const upstream = net.connect(port, host);
130
132
  const tearDown = (reason) => {
131
133
  if (closed)
132
134
  return;
133
135
  closed = true;
136
+ if (idleTimer)
137
+ clearInterval(idleTimer);
134
138
  upstream.destroy();
135
139
  clientSocket.destroy();
136
140
  this.stats.active = Math.max(0, this.stats.active - 1);
@@ -181,6 +185,26 @@ export class ConnectProxy {
181
185
  upstream.on("end", () => tearDown("upstream end"));
182
186
  clientSocket.on("error", (err) => tearDown(`client error: ${err.message}`));
183
187
  clientSocket.on("end", () => tearDown("client end"));
188
+ // "close" catches abrupt teardowns (RST / socket destroyed) that never
189
+ // emit "end" — without these, a half-dead tunnel leaks.
190
+ upstream.on("close", () => tearDown("upstream close"));
191
+ clientSocket.on("close", () => tearDown("client close"));
192
+ // Idle reaper: when the Carrier path drops mid-stream the broken side
193
+ // sends no FIN, so neither "end" nor "error" fires and the sockets would
194
+ // leak until the kernel's multi-hour TCP keepalive — they pile up under
195
+ // load (1080p opens many high-bandwidth tunnels; two clients on one exit
196
+ // compounds it) and starve the exit. If no bytes flow in EITHER direction
197
+ // across two idle ticks, tear it down. Active streaming keeps `bytes`
198
+ // climbing (and backpressure pauses a one-sided flow), so only genuinely
199
+ // dead tunnels are reaped. Tunable via AGENTNET_TUNNEL_IDLE_MS.
200
+ const idleMs = Number(process.env.AGENTNET_TUNNEL_IDLE_MS) || 120_000;
201
+ idleTimer = setInterval(() => {
202
+ if (bytes === lastBytes)
203
+ tearDown("idle (no data — Carrier path likely dropped)");
204
+ else
205
+ lastBytes = bytes;
206
+ }, idleMs);
207
+ idleTimer.unref?.();
184
208
  }
185
209
  refuse(clientSocket, src, srcName, target, code, reason) {
186
210
  this.stats.totalRefused += 1;
@@ -0,0 +1,17 @@
1
+ import type { IpcRequest, IpcResponse } from "../daemon/ipc.js";
2
+ export interface FriendUiOptions {
3
+ /** Call the running daemon over IPC (injected so this module stays
4
+ * decoupled from the CLI's ipc client). */
5
+ call: (req: IpcRequest) => Promise<IpcResponse>;
6
+ /** Path to ~/.agentnet/routes.yaml — the exit-routing table the UI edits. */
7
+ routesPath: string;
8
+ /** If this node also runs a dora server, the path to its roster.yaml — the
9
+ * UI shows the allocation table (userid → IP → name). Undefined = no dora. */
10
+ doraRosterPath?: string;
11
+ listenHost?: string;
12
+ listenPort?: number;
13
+ log?: (msg: string) => void;
14
+ }
15
+ export declare function startFriendUi(opts: FriendUiOptions): {
16
+ stop: () => void;
17
+ };
@@ -0,0 +1,315 @@
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 { existsSync, readFileSync, writeFileSync } from "node:fs";
17
+ import yaml from "js-yaml";
18
+ export function startFriendUi(opts) {
19
+ const host = opts.listenHost ?? "127.0.0.1";
20
+ const port = opts.listenPort ?? 8765;
21
+ const log = opts.log ?? ((s) => console.log(s));
22
+ const sendJson = (res, code, body) => {
23
+ res.writeHead(code, { "content-type": "application/json" });
24
+ res.end(JSON.stringify(body));
25
+ };
26
+ const readBody = (req) => new Promise((resolve) => {
27
+ let b = "";
28
+ req.on("data", (c) => (b += c));
29
+ req.on("end", () => {
30
+ try {
31
+ resolve(b ? JSON.parse(b) : {});
32
+ }
33
+ catch {
34
+ resolve({});
35
+ }
36
+ });
37
+ });
38
+ const server = http.createServer(async (req, res) => {
39
+ try {
40
+ const url = (req.url || "/").split("?")[0];
41
+ if (req.method === "GET" && (url === "/" || url === "/index.html")) {
42
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
43
+ res.end(PAGE);
44
+ return;
45
+ }
46
+ if (req.method === "GET" && url === "/api/state") {
47
+ const [diag, pending] = await Promise.all([
48
+ opts.call({ op: "diag" }),
49
+ opts.call({ op: "friends-pending" }),
50
+ ]);
51
+ const d = diag.ok ? (diag.data ?? {}) : {};
52
+ const identity = d.identity ?? {};
53
+ const tun = d.tun ?? {};
54
+ const me = {
55
+ userid: identity.userid,
56
+ address: identity.address,
57
+ ip: d.allocatedIp ?? tun.ip,
58
+ installed: !!tun.ip, // lan/TUN is up
59
+ };
60
+ const friends = d.friends ?? [];
61
+ const pend = pending.ok ? (pending.data?.pending ?? []) : [];
62
+ sendJson(res, 200, { me, friends, pending: pend });
63
+ return;
64
+ }
65
+ if (req.method === "GET" && url === "/api/chat-history") {
66
+ const r = await opts.call({ op: "chat-history" });
67
+ sendJson(res, 200, r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
68
+ return;
69
+ }
70
+ if (req.method === "POST" && url === "/api/chat-send") {
71
+ const { userid, text } = await readBody(req);
72
+ const r = await opts.call({ op: "chat-send", userid, text });
73
+ sendJson(res, r.ok ? 200 : 400, r);
74
+ return;
75
+ }
76
+ if (req.method === "GET" && url === "/api/routes") {
77
+ let routes = { regions: [], default: "direct" };
78
+ if (existsSync(opts.routesPath)) {
79
+ routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
80
+ }
81
+ const diag = await opts.call({ op: "diag" });
82
+ const available = diag.ok ? (diag.data?.ipam ?? []) : [];
83
+ sendJson(res, 200, { routes, available });
84
+ return;
85
+ }
86
+ if (req.method === "GET" && url === "/api/dora") {
87
+ let records = [];
88
+ if (opts.doraRosterPath && existsSync(opts.doraRosterPath)) {
89
+ const r = yaml.load(readFileSync(opts.doraRosterPath, "utf-8"));
90
+ records = r?.records ?? [];
91
+ }
92
+ sendJson(res, 200, { isDora: !!opts.doraRosterPath, records });
93
+ return;
94
+ }
95
+ if (req.method === "POST" && url === "/api/routes") {
96
+ const body = await readBody(req);
97
+ try {
98
+ const routes = JSON.parse(body.routes ?? "{}");
99
+ writeFileSync(opts.routesPath, yaml.dump(routes, { lineWidth: -1 }), "utf-8");
100
+ sendJson(res, 200, { ok: true });
101
+ }
102
+ catch (err) {
103
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
104
+ }
105
+ return;
106
+ }
107
+ if (req.method === "POST" && url === "/api/accept") {
108
+ const { userid } = await readBody(req);
109
+ const r = await opts.call({ op: "friends-accept", userid });
110
+ sendJson(res, r.ok ? 200 : 400, r);
111
+ return;
112
+ }
113
+ if (req.method === "POST" && url === "/api/reject") {
114
+ const { userid } = await readBody(req);
115
+ const r = await opts.call({ op: "friends-reject", userid });
116
+ sendJson(res, r.ok ? 200 : 400, r);
117
+ return;
118
+ }
119
+ if (req.method === "POST" && url === "/api/add") {
120
+ const { address } = await readBody(req);
121
+ const r = await opts.call({ op: "friend-request", address });
122
+ sendJson(res, r.ok ? 200 : 400, r);
123
+ return;
124
+ }
125
+ res.writeHead(404);
126
+ res.end("not found");
127
+ }
128
+ catch (err) {
129
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
130
+ }
131
+ });
132
+ server.listen(port, host, () => {
133
+ log(`Friend UI on http://${host}:${port}`);
134
+ log(`(serves the running daemon's friends + pending requests over IPC)`);
135
+ });
136
+ return { stop: () => server.close() };
137
+ }
138
+ const PAGE = `<!doctype html>
139
+ <html lang="en"><head><meta charset="utf-8"/>
140
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
141
+ <title>decentlan — friends</title>
142
+ <style>
143
+ :root { color-scheme: light dark; }
144
+ body { font: 15px/1.5 -apple-system, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
145
+ h1 { font-size: 1.3rem; } h2 { font-size: 1rem; margin: 1.5rem 0 .5rem; color: #888; text-transform: uppercase; letter-spacing: .04em; }
146
+ .row { display: flex; align-items: center; gap: .75rem; padding: .55rem .7rem; border: 1px solid #8883; border-radius: 8px; margin-bottom: .4rem; }
147
+ .row .meta { flex: 1; min-width: 0; }
148
+ .row .name { font-weight: 600; }
149
+ .row .sub { font-size: .8rem; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150
+ .dot { width: .6rem; height: .6rem; border-radius: 50%; flex: 0 0 auto; }
151
+ .online { background: #2ecc71; } .offline { background: #bbb; } .requested { background: #f1c40f; }
152
+ button { font: inherit; padding: .3rem .7rem; border-radius: 6px; border: 1px solid #8886; background: transparent; cursor: pointer; }
153
+ button.accept { border-color: #2ecc71; color: #2ecc71; } button.reject { border-color: #e74c3c; color: #e74c3c; }
154
+ button:hover { background: #8881; }
155
+ .add { display: flex; gap: .5rem; margin: .5rem 0 1.5rem; }
156
+ .add input { flex: 1; font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid #8886; background: transparent; color: inherit; }
157
+ .empty { color: #999; font-style: italic; padding: .4rem 0; }
158
+ #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; }
159
+ #toast.show { opacity: 1; }
160
+ </style></head>
161
+ <body>
162
+ <h1>decentlan · friends</h1>
163
+ <div id="me" class="sub" style="margin:-.5rem 0 1rem"></div>
164
+
165
+ <div class="add">
166
+ <input id="addr" placeholder="paste a friend's address to send a request" autocomplete="off"/>
167
+ <button onclick="addFriend()">Add</button>
168
+ </div>
169
+
170
+ <h2>Friend requests <span id="pcount"></span></h2>
171
+ <div id="pending"></div>
172
+
173
+ <div id="doraPanel" style="display:none">
174
+ <h2>Dora allocations <span id="dcount"></span></h2>
175
+ <div id="dora"></div>
176
+ </div>
177
+
178
+ <h2>Exit nodes <span id="ecount"></span></h2>
179
+ <div id="exits"></div>
180
+ <div class="add">
181
+ <input id="exitIp" placeholder="exit virtual IP (e.g. 10.86.1.15)" autocomplete="off" style="flex:2"/>
182
+ <input id="exitRegion" placeholder="region (china/japan/us)" autocomplete="off" list="regionList" style="flex:1"/>
183
+ <datalist id="regionList"><option>china</option><option>japan</option><option>us</option></datalist>
184
+ <button onclick="addExit()">Add exit</button>
185
+ </div>
186
+
187
+ <h2>Friends <span id="fcount"></span></h2>
188
+ <div id="friends"></div>
189
+
190
+ <div id="chat" style="display:none; position:fixed; inset:0; background:Canvas; padding:1.5rem 1rem; max-width:760px; margin:0 auto;">
191
+ <div class="row" style="border:none; padding-left:0">
192
+ <button onclick="closeChat()">← Back</button>
193
+ <div class="meta"><div class="name" id="chatName"></div><div class="sub" id="chatSub"></div></div>
194
+ </div>
195
+ <div id="chatLog" style="height:calc(100vh - 13rem); overflow-y:auto; display:flex; flex-direction:column; gap:.35rem; padding:.5rem 0;"></div>
196
+ <div class="add"><input id="chatInput" placeholder="message…" autocomplete="off"/><button onclick="sendChat()">Send</button></div>
197
+ </div>
198
+
199
+ <div id="toast"></div>
200
+ <script>
201
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
202
+ const short = s => { s = String(s ?? ''); return s.length > 20 ? s.slice(0,10)+'…'+s.slice(-6) : s; };
203
+ let toastT;
204
+ function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('show'),2200); }
205
+
206
+ 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(); }
207
+
208
+ window.friendsById = {};
209
+ async function refresh(){
210
+ let s; try { s = await api('/api/state'); } catch(e){ return; }
211
+ const pend = s.pending || [], fr = s.friends || [], me = s.me || {};
212
+ document.getElementById('me').textContent = me.ip
213
+ ? ('My IP: ' + me.ip + (me.userid ? ' · ' + short(me.userid) : ''))
214
+ : (me.userid ? ('userid: ' + short(me.userid) + ' · lan/TUN not up') : 'daemon: no identity');
215
+ fr.forEach(f => { window.friendsById[f.userid||f.carrierId] = f; });
216
+ document.getElementById('pcount').textContent = pend.length ? '('+pend.length+')' : '';
217
+ document.getElementById('fcount').textContent = fr.length ? '('+fr.length+')' : '';
218
+ document.getElementById('pending').innerHTML = pend.length ? pend.map(p => \`
219
+ <div class="row">
220
+ <div class="meta"><div class="name">\${esc(p.name || 'unnamed')}</div>
221
+ <div class="sub">\${esc(short(p.userid))}\${p.hello? ' · "'+esc(p.hello)+'"':''}</div></div>
222
+ <button class="accept" onclick="act('accept','\${esc(p.userid)}')">Accept</button>
223
+ <button class="reject" onclick="act('reject','\${esc(p.userid)}')">Reject</button>
224
+ </div>\`).join('') : '<div class="empty">No pending requests.</div>';
225
+ document.getElementById('friends').innerHTML = fr.length ? fr.map(f => \`
226
+ <div class="row" style="cursor:pointer" onclick="openChat('\${esc(f.userid||f.carrierId)}')" title="open chat">
227
+ <span class="dot \${esc(f.status||'offline')}"></span>
228
+ <div class="meta"><div class="name">\${esc(f.name || 'unnamed')}</div>
229
+ <div class="sub">\${esc(f.status||'')} · \${esc(short(f.userid||f.carrierId))}\${f.virtualIp? ' · '+esc(f.virtualIp):''}</div></div>
230
+ </div>\`).join('') : '<div class="empty">No friends yet.</div>';
231
+ }
232
+ async function act(kind, userid){ const r = await api('/api/'+kind, {userid}); toast(r.ok? (kind==='accept'?'Accepted':'Rejected') : (r.error||'failed')); refresh(); }
233
+ 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(); }
234
+ document.getElementById('addr').addEventListener('keydown', e=>{ if(e.key==='Enter') addFriend(); });
235
+
236
+ let chatWith = null, chatTimer = null;
237
+ async function openChat(userid){
238
+ chatWith = userid;
239
+ const f = window.friendsById[userid] || {};
240
+ document.getElementById('chatName').textContent = f.name || 'unnamed';
241
+ document.getElementById('chatSub').textContent = (f.status||'') + ' · ' + short(userid);
242
+ document.getElementById('chat').style.display = 'block';
243
+ await renderChat();
244
+ clearInterval(chatTimer); chatTimer = setInterval(renderChat, 2000);
245
+ document.getElementById('chatInput').focus();
246
+ }
247
+ function closeChat(){ chatWith = null; clearInterval(chatTimer); document.getElementById('chat').style.display='none'; refresh(); }
248
+ async function renderChat(){
249
+ if(!chatWith) return;
250
+ let h; try { h = await api('/api/chat-history'); } catch(e){ return; }
251
+ const msgs = (h.chats && h.chats[chatWith]) || [];
252
+ const log = document.getElementById('chatLog');
253
+ 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>';
254
+ log.scrollTop = log.scrollHeight;
255
+ }
256
+ async function sendChat(){
257
+ const i = document.getElementById('chatInput'); const t = i.value.trim();
258
+ if(!t || !chatWith) return;
259
+ i.value='';
260
+ const r = await api('/api/chat-send', {userid: chatWith, text: t});
261
+ if(!r.ok) toast(r.error||'send failed');
262
+ renderChat();
263
+ }
264
+ document.getElementById('chatInput').addEventListener('keydown', e=>{ if(e.key==='Enter') sendChat(); });
265
+
266
+ // ---- Exit nodes (routes.yaml) ----
267
+ let routesObj = { regions: [], default: 'direct' };
268
+ async function refreshExits(){
269
+ let r; try { r = await api('/api/routes'); } catch(e){ return; }
270
+ routesObj = r.routes || { regions: [], default: 'direct' };
271
+ if(!Array.isArray(routesObj.regions)) routesObj.regions = [];
272
+ const regions = routesObj.regions;
273
+ const total = regions.reduce((n,rg)=>n+((rg.exits||[]).length),0);
274
+ document.getElementById('ecount').textContent = total ? '('+total+')' : '';
275
+ document.getElementById('exits').innerHTML = regions.length ? regions.map((rg)=> \`
276
+ <div class="row" style="flex-direction:column; align-items:stretch; gap:.3rem">
277
+ <div class="name">\${esc(rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
278
+ \${(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('')}
279
+ </div>\`).join('') : '<div class="empty">No routes.yaml regions — using auto-discovery.</div>';
280
+ }
281
+ function saveRoutes(){ return api('/api/routes', { routes: JSON.stringify(routesObj) }); }
282
+ async function addExit(){
283
+ const ip=document.getElementById('exitIp').value.trim(), region=(document.getElementById('exitRegion').value.trim()||'china');
284
+ if(!ip) return;
285
+ let rg = routesObj.regions.find(x=>x.name===region);
286
+ if(!rg){ rg={name:region, exits:[]}; routesObj.regions.push(rg); }
287
+ rg.exits = rg.exits||[]; if(!rg.exits.includes(ip)) rg.exits.push(ip);
288
+ const r = await saveRoutes(); toast(r.ok?'Added — restart the router to apply':(r.error||'failed'));
289
+ document.getElementById('exitIp').value=''; refreshExits();
290
+ }
291
+ async function removeExit(region, ip){
292
+ const rg = routesObj.regions.find(x=>x.name===region); if(!rg) return;
293
+ rg.exits = (rg.exits||[]).filter(x=>x!==ip);
294
+ const r = await saveRoutes(); toast(r.ok?'Removed — restart the router to apply':(r.error||'failed')); refreshExits();
295
+ }
296
+
297
+ // ---- Dora allocations (if this node runs a dora server) ----
298
+ async function refreshDora(){
299
+ let d; try { d = await api('/api/dora'); } catch(e){ return; }
300
+ const panel = document.getElementById('doraPanel');
301
+ if(!d.isDora){ panel.style.display='none'; return; }
302
+ panel.style.display='block';
303
+ const recs = d.records || [];
304
+ document.getElementById('dcount').textContent = '('+recs.length+')';
305
+ document.getElementById('dora').innerHTML = recs.length ? recs.map(r => \`
306
+ <div class="row">
307
+ <div class="meta"><div class="name">\${esc(r.name||'unnamed')} · \${esc(r.virtualIp||'')}</div>
308
+ <div class="sub">\${esc(short(r.userid))}</div></div>
309
+ </div>\`).join('') : '<div class="empty">No nodes registered yet.</div>';
310
+ }
311
+
312
+ refresh(); refreshExits(); refreshDora();
313
+ setInterval(refresh, 3000); setInterval(refreshExits, 8000); setInterval(refreshDora, 5000);
314
+ </script>
315
+ </body></html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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",