@decentnetwork/lan 0.1.234 → 0.1.236

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.
@@ -17,6 +17,7 @@ import yaml from "js-yaml";
17
17
  import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
18
18
  import { resolveEgressBindIp } from "../proxy/egress.js";
19
19
  import { startFriendUi } from "../ui/server.js";
20
+ import { installLogThrottle } from "../utils/log-throttle.js";
20
21
  /**
21
22
  * Refuse to open a second Carrier peer with this identity if the
22
23
  * daemon is already running with the same keypair. Two peers sharing
@@ -442,6 +443,12 @@ export async function cmdUp(args) {
442
443
  if (args.name) {
443
444
  config.node.name = args.name;
444
445
  }
446
+ // Collapse repeated log lines before they reach stdout. A daemon that runs
447
+ // for weeks repeats the same message shape endlessly (a peer re-keying, an
448
+ // onion path failing); unthrottled that produced a 12 GB log file, and a
449
+ // full disk stops the daemon outright. Covers the SDK's [peer-debug] output
450
+ // too, since this wraps the process console rather than our Logger.
451
+ installLogThrottle();
445
452
  const daemon = new DaemonServer({
446
453
  config,
447
454
  configDir: dir,
@@ -2390,6 +2397,22 @@ WantedBy=multi-user.target
2390
2397
  const msg = err instanceof Error ? err.message : String(err);
2391
2398
  throw new Error(`Could not write ${plistPath} (need root? try 'sudo'): ${msg}`);
2392
2399
  }
2400
+ // launchd never rotates StandardOutPath — the file grows until the disk is
2401
+ // full and the daemon dies with it (a 12 GB agentnet log is how we found
2402
+ // this). systemd users get journald rotation for free; on macOS the native
2403
+ // equivalent is a newsyslog rule, so install one alongside the plist:
2404
+ // rotate at 10 MB, keep 5 compressed generations.
2405
+ try {
2406
+ const fs = await import("fs/promises");
2407
+ await fs.mkdir("/etc/newsyslog.d", { recursive: true });
2408
+ await fs.writeFile("/etc/newsyslog.d/com.decentlan.agentnet.conf", "# logfilename [owner:group] mode count size(KB) when flags\n" +
2409
+ "/var/log/agentnet.log root:wheel 644 5 10240 * GZ\n", "utf-8");
2410
+ console.log("Log rotation: /var/log/agentnet.log rotates at 10 MB, 5 compressed generations kept.");
2411
+ }
2412
+ catch (err) {
2413
+ // Not fatal — the daemon's own log throttling already bounds the volume.
2414
+ console.log(`Note: could not install the newsyslog rotation rule (${err instanceof Error ? err.message : err}). The log is still throttled in-process.`);
2415
+ }
2393
2416
  execSync(`launchctl load ${plistPath}`);
2394
2417
  console.log(`Installed ${plistPath} and started com.decentlan.agentnet.`);
2395
2418
  console.log(`Optional — watch China video (CCTV etc.): agentnet proxy trust-ca && agentnet proxy router --hls-accel (docs/CCTV-VIEWING.md)`);
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.234";
1
+ window.__DK_UI_VERSION="0.1.236";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -3002,6 +3002,7 @@ function DkApp() {
3002
3002
  sessionStorage.setItem(key, "1");
3003
3003
  window.location.reload();
3004
3004
  }, [me && me.lanVer]);
3005
+ const peers = data.peers;
3005
3006
  const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
3006
3007
  React.useEffect(() => {
3007
3008
  const onHash = () => {
@@ -3030,7 +3031,6 @@ function DkApp() {
3030
3031
  }
3031
3032
  }
3032
3033
  }, [pendingAddr, peers]);
3033
- const peers = data.peers;
3034
3034
  const requests = data.requests;
3035
3035
  const exits = data.exits;
3036
3036
  const activeExit = data.activeExit;
package/dist/ui/server.js CHANGED
@@ -242,7 +242,9 @@ export function startFriendUi(opts) {
242
242
  }
243
243
  });
244
244
  });
245
- const server = http.createServer(async (req, res) => {
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) => {
246
248
  try {
247
249
  const url = (req.url || "/").split("?")[0];
248
250
  // Desktop UI bundle (the design). Falls back to the classic page when
@@ -922,12 +924,39 @@ export function startFriendUi(opts) {
922
924
  catch (err) {
923
925
  sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
924
926
  }
925
- });
926
- server.listen(port, host, () => {
927
- log(`Friend UI on http://${host}:${port}`);
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
+ server.listen(port, host === "localhost" ? "127.0.0.1" : host, () => {
938
+ log(`Friend UI on http://${isLoopback ? "localhost" : host}:${port}`);
928
939
  log(`(serves the running daemon's friends + pending requests over IPC)`);
929
940
  });
930
- return { stop: () => server.close() };
941
+ if (isLoopback) {
942
+ // Second server object on ::1: Node can't bind one listener to two
943
+ // specific addresses, and binding :: instead would expose the UI on every
944
+ // interface — the opposite of what a loopback default promises.
945
+ v6 = http.createServer(handler);
946
+ v6.on("error", (err) => {
947
+ // A host with IPv6 disabled gives EADDRNOTAVAIL/EAFNOSUPPORT; the IPv4
948
+ // listener above is already serving, so this is not fatal.
949
+ if (err.code !== "EADDRINUSE")
950
+ log(`(IPv6 loopback unavailable: ${err.code ?? err.message} — IPv4 still serving)`);
951
+ });
952
+ v6.listen(port, "::1");
953
+ }
954
+ return {
955
+ stop: () => {
956
+ server.close();
957
+ v6?.close();
958
+ },
959
+ };
931
960
  }
932
961
  const PAGE = `<!doctype html>
933
962
  <html lang="en"><head><meta charset="utf-8"/>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Bound the daemon's log volume by collapsing repeated lines.
3
+ *
4
+ * A long-running daemon emits the same message shape over and over — a peer
5
+ * that keeps re-keying, an onion path that keeps failing, a node blacklisted
6
+ * on a loop. Left alone that filled a 12 GB log file. The value of the 30,000th
7
+ * copy of a line is zero, but the disk cost is not, and on a full disk the
8
+ * daemon stops working entirely.
9
+ *
10
+ * This wraps the process's console so EVERY writer is covered, including the
11
+ * `[peer-debug]` output from `@decentnetwork/peer` (which logs via console and
12
+ * so can't be reached by our own Logger class). Messages are bucketed by
13
+ * "shape" — ids, addresses and numbers stripped — so a line that differs only
14
+ * by which peer or port it names still collapses into one bucket.
15
+ *
16
+ * Behaviour per shape, per window: print the first BURST copies verbatim, then
17
+ * go quiet; when the window closes, print a single "(… ×N suppressed)" line so
18
+ * the volume is still visible. Nothing is dropped silently.
19
+ */
20
+ export interface LogThrottleOptions {
21
+ /** Copies of one shape printed verbatim before suppression. */
22
+ burst?: number;
23
+ /** Window length; the counter and suppression reset after this. */
24
+ windowMs?: number;
25
+ /** Distinct shapes tracked at once (guards the map itself from a leak). */
26
+ maxShapes?: number;
27
+ }
28
+ /** Collapse a message to its shape: strip ids, IPs, numbers and durations so
29
+ * the same event about different peers shares one bucket. */
30
+ export declare function logShape(message: string): string;
31
+ /**
32
+ * Install the throttle on `console`. Returns a restore function (used by tests;
33
+ * the daemon never uninstalls).
34
+ */
35
+ export declare function installLogThrottle(opts?: LogThrottleOptions): () => void;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Bound the daemon's log volume by collapsing repeated lines.
3
+ *
4
+ * A long-running daemon emits the same message shape over and over — a peer
5
+ * that keeps re-keying, an onion path that keeps failing, a node blacklisted
6
+ * on a loop. Left alone that filled a 12 GB log file. The value of the 30,000th
7
+ * copy of a line is zero, but the disk cost is not, and on a full disk the
8
+ * daemon stops working entirely.
9
+ *
10
+ * This wraps the process's console so EVERY writer is covered, including the
11
+ * `[peer-debug]` output from `@decentnetwork/peer` (which logs via console and
12
+ * so can't be reached by our own Logger class). Messages are bucketed by
13
+ * "shape" — ids, addresses and numbers stripped — so a line that differs only
14
+ * by which peer or port it names still collapses into one bucket.
15
+ *
16
+ * Behaviour per shape, per window: print the first BURST copies verbatim, then
17
+ * go quiet; when the window closes, print a single "(… ×N suppressed)" line so
18
+ * the volume is still visible. Nothing is dropped silently.
19
+ */
20
+ /** Collapse a message to its shape: strip ids, IPs, numbers and durations so
21
+ * the same event about different peers shares one bucket. */
22
+ export function logShape(message) {
23
+ return message
24
+ .replace(/\b[0-9a-fA-F]{16,}\b/g, "<hex>")
25
+ .replace(/\b[1-9A-HJ-NP-Za-km-z]{40,}\b/g, "<id>") // base58 userid/address
26
+ .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "<ip>")
27
+ // No word boundaries: counters are routinely glued to a unit ("60s",
28
+ // "250ms", "step2"). Requiring \b left those unnormalized, so every
29
+ // distinct duration became its own bucket and nothing collapsed.
30
+ .replace(/\d+(\.\d+)?/g, "<n>")
31
+ .slice(0, 200);
32
+ }
33
+ /**
34
+ * Install the throttle on `console`. Returns a restore function (used by tests;
35
+ * the daemon never uninstalls).
36
+ */
37
+ export function installLogThrottle(opts = {}) {
38
+ const burst = opts.burst ?? 5;
39
+ const windowMs = opts.windowMs ?? 60_000;
40
+ const maxShapes = opts.maxShapes ?? 500;
41
+ const shapes = new Map();
42
+ const original = {
43
+ log: console.log.bind(console),
44
+ debug: console.debug.bind(console),
45
+ info: console.info.bind(console),
46
+ warn: console.warn.bind(console),
47
+ error: console.error.bind(console),
48
+ };
49
+ const wrap = (emit) => (message, ...args) => {
50
+ // Only throttle plain string messages; structured/objects pass through.
51
+ if (typeof message !== "string") {
52
+ emit(message, ...args);
53
+ return;
54
+ }
55
+ const now = Date.now();
56
+ const key = logShape(message);
57
+ let st = shapes.get(key);
58
+ if (!st) {
59
+ // Bound the tracking map. Evicting the oldest window is enough: a
60
+ // genuinely hot shape re-registers on its next line.
61
+ if (shapes.size >= maxShapes) {
62
+ let oldestKey;
63
+ let oldest = Infinity;
64
+ for (const [k, v] of shapes) {
65
+ if (v.windowStart < oldest) {
66
+ oldest = v.windowStart;
67
+ oldestKey = k;
68
+ }
69
+ }
70
+ if (oldestKey)
71
+ shapes.delete(oldestKey);
72
+ }
73
+ st = { count: 0, windowStart: now, suppressed: 0, sample: message };
74
+ shapes.set(key, st);
75
+ }
76
+ if (now - st.windowStart >= windowMs) {
77
+ // Window closed — report what we swallowed, then start fresh.
78
+ if (st.suppressed > 0) {
79
+ emit(`… ${st.suppressed} more like this suppressed in the last ${Math.round(windowMs / 1000)}s: ${st.sample.slice(0, 160)}`);
80
+ }
81
+ st.count = 0;
82
+ st.suppressed = 0;
83
+ st.windowStart = now;
84
+ }
85
+ st.count++;
86
+ if (st.count <= burst) {
87
+ emit(message, ...args);
88
+ return;
89
+ }
90
+ st.suppressed++;
91
+ };
92
+ console.log = wrap(original.log);
93
+ console.debug = wrap(original.debug);
94
+ console.info = wrap(original.info);
95
+ console.warn = wrap(original.warn);
96
+ // Errors are rare and each one matters — never suppress them.
97
+ console.error = original.error;
98
+ return () => {
99
+ console.log = original.log;
100
+ console.debug = original.debug;
101
+ console.info = original.info;
102
+ console.warn = original.warn;
103
+ console.error = original.error;
104
+ };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.234",
3
+ "version": "0.1.236",
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",