@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.
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +43 -0
- package/dist/cli/index.js +8 -1
- package/dist/daemon/ipc.d.ts +6 -1
- package/dist/daemon/ipc.js +8 -0
- package/dist/daemon/server.d.ts +5 -0
- package/dist/daemon/server.js +30 -0
- package/dist/proxy/connect-proxy.js +24 -0
- package/dist/ui/server.d.ts +17 -0
- package/dist/ui/server.js +315 -0
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/commands.js
CHANGED
|
@@ -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)
|
package/dist/daemon/ipc.d.ts
CHANGED
|
@@ -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;
|
package/dist/daemon/ipc.js
CHANGED
|
@@ -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":
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/daemon/server.js
CHANGED
|
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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.
|
|
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",
|