@decentnetwork/lan 0.1.229 → 0.1.231

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.
@@ -381,13 +381,27 @@ export async function cmdStatus(args) {
381
381
  return;
382
382
  }
383
383
  const config = await ConfigLoader.load(configPath);
384
+ let liveTun = null;
385
+ try {
386
+ const res = await ipcCall(config, { op: "diag" }, 5_000);
387
+ if (res.ok)
388
+ liveTun = (res.data?.tun) ?? null;
389
+ }
390
+ catch {
391
+ // daemon not running — fall back to config below
392
+ }
393
+ const running = !!liveTun;
384
394
  console.log(`Decent AgentNet`);
385
395
  console.log(`Node: ${config.node.name}`);
386
396
  console.log(`Namespace: ${config.node.namespace}`);
387
- console.log(`Subnet: ${config.network.subnet}`);
388
- console.log(`Interface: ${config.network.interface}`);
389
- console.log(`Virtual IP: ${config.network.ip}`);
390
- console.log(`Bootstrap: ${config.carrier.bootstrapNodes.join(", ")}`);
397
+ console.log(`Daemon: ${running ? "running" : "not running (values below are configured, not live)"}`);
398
+ console.log(`Subnet: ${liveTun?.subnet ?? config.network.subnet}`);
399
+ console.log(`Interface: ${liveTun?.name ?? config.network.interface}${running ? "" : " (configured)"}`);
400
+ console.log(`Virtual IP: ${liveTun?.ip ?? config.network.ip}${running ? "" : " (configured)"}`);
401
+ if (running && liveTun?.ip && liveTun.ip !== config.network.ip) {
402
+ console.log(` note: config.yaml still says ${config.network.ip} — the daemon's address wins`);
403
+ }
404
+ console.log(`Bootstrap: ${config.carrier.bootstrapNodes.length} nodes`);
391
405
  // Show IPAM peers count
392
406
  const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
393
407
  console.log(`Peers configured: ${ipam.getPeers().length}`);
@@ -22,7 +22,34 @@ export interface DefaultDora {
22
22
  name: string;
23
23
  userid: string;
24
24
  address: string;
25
+ /** The IP band this registry allocates from, `<start>-<end>`. A dora is
26
+ * authoritative for exactly this range and nothing else, so the bands
27
+ * MUST NOT overlap: two registries handing out the same address give two
28
+ * nodes the same virtual IP. This is real data, not a comment — see
29
+ * `findDoraSegmentOverlaps`, which fails a startup check on collisions. */
30
+ segment?: string;
25
31
  }
32
+ /** Parse `"<start>-<end>"` into numeric bounds. */
33
+ export declare function parseDoraSegment(segment?: string): {
34
+ start: number;
35
+ end: number;
36
+ } | null;
37
+ /**
38
+ * Every pair of registries whose allocation bands intersect.
39
+ *
40
+ * Segment uniqueness used to be a convention in a comment, enforced by
41
+ * nothing: the allocator even defaulted to the whole /16, so a
42
+ * misconfigured registry silently claimed its siblings' ranges and handed
43
+ * out addresses inside them. Two nodes then hold the same virtual IP and
44
+ * routing to either is a coin flip. Making it checkable is what turns the
45
+ * convention into an invariant.
46
+ */
47
+ export declare function findDoraSegmentOverlaps(doras: DefaultDora[]): Array<{
48
+ a: string;
49
+ b: string;
50
+ segmentA: string;
51
+ segmentB: string;
52
+ }>;
26
53
  export declare const DEFAULT_DORAS: DefaultDora[];
27
54
  export declare const DEFAULT_DORA_USERID: string;
28
55
  export declare const DEFAULT_DORA_ADDRESS: string;
@@ -47,10 +47,63 @@ const DEFAULT_EXPRESS_NODES = [
47
47
  { host: "tokyo.fi.chat", port: 8443, pk: "EzpBtoUkjeMQfuLGWwTUbvzCn9rK4J648Ziy21EKxefo" },
48
48
  ];
49
49
  const DEFAULT_DORAS_FALLBACK = [
50
- { name: "dora-beagle", userid: "AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN", address: "NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM" }, // 10.86.64.10127.254
51
- { name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK" }, // 10.86.128.10191.254
52
- { name: "dora-tokyo", userid: "AB6BZfbrTFWw9eUoVpHdJqhhRnY8bTttp4CHTZ2Xfzxi", address: "MAW2eBqBuQ6SmaXTrnZRRayQjAj3aLatwPy4xmBp7spnJeV569op" }, // 10.86.192.10254.254
50
+ { name: "dora-beagle", userid: "AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN", address: "NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM", segment: "10.86.64.10-10.86.127.254" },
51
+ { name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK", segment: "10.86.128.10-10.86.191.254" },
52
+ { name: "dora-tokyo", userid: "AB6BZfbrTFWw9eUoVpHdJqhhRnY8bTttp4CHTZ2Xfzxi", address: "MAW2eBqBuQ6SmaXTrnZRRayQjAj3aLatwPy4xmBp7spnJeV569op", segment: "10.86.192.10-10.86.254.254" },
53
53
  ];
54
+ /** `10.86.64.10` → 176177162, or null when unparseable. */
55
+ function ipToNum(ip) {
56
+ const parts = ip.trim().split(".");
57
+ if (parts.length !== 4)
58
+ return null;
59
+ let n = 0;
60
+ for (const p of parts) {
61
+ const v = Number(p);
62
+ if (!Number.isInteger(v) || v < 0 || v > 255)
63
+ return null;
64
+ n = n * 256 + v;
65
+ }
66
+ return n;
67
+ }
68
+ /** Parse `"<start>-<end>"` into numeric bounds. */
69
+ export function parseDoraSegment(segment) {
70
+ if (!segment)
71
+ return null;
72
+ const [a, b] = segment.split("-");
73
+ if (!a || !b)
74
+ return null;
75
+ const start = ipToNum(a);
76
+ const end = ipToNum(b);
77
+ if (start === null || end === null || start > end)
78
+ return null;
79
+ return { start, end };
80
+ }
81
+ /**
82
+ * Every pair of registries whose allocation bands intersect.
83
+ *
84
+ * Segment uniqueness used to be a convention in a comment, enforced by
85
+ * nothing: the allocator even defaulted to the whole /16, so a
86
+ * misconfigured registry silently claimed its siblings' ranges and handed
87
+ * out addresses inside them. Two nodes then hold the same virtual IP and
88
+ * routing to either is a coin flip. Making it checkable is what turns the
89
+ * convention into an invariant.
90
+ */
91
+ export function findDoraSegmentOverlaps(doras) {
92
+ const parsed = doras
93
+ .map((d) => ({ name: d.name, segment: d.segment, range: parseDoraSegment(d.segment) }))
94
+ .filter((d) => !!d.range);
95
+ const clashes = [];
96
+ for (let i = 0; i < parsed.length; i++) {
97
+ for (let j = i + 1; j < parsed.length; j++) {
98
+ const x = parsed[i];
99
+ const y = parsed[j];
100
+ if (x.range.start <= y.range.end && y.range.start <= x.range.end) {
101
+ clashes.push({ a: x.name, b: y.name, segmentA: x.segment, segmentB: y.segment });
102
+ }
103
+ }
104
+ }
105
+ return clashes;
106
+ }
54
107
  function loadDefaultDoras() {
55
108
  // dist/config/loader.js → package root is two levels up; data file ships
56
109
  // at <pkg>/config/default-doras.yaml. Same path holds when running from
@@ -59,8 +112,16 @@ function loadDefaultDoras() {
59
112
  const file = resolve(dirname(new URL(import.meta.url).pathname), "../../config/default-doras.yaml");
60
113
  const parsed = yaml.load(readFileSync(file, "utf-8"));
61
114
  const doras = (parsed?.doras ?? []).filter((d) => d && d.userid && d.address);
62
- if (doras.length > 0)
115
+ if (doras.length > 0) {
116
+ // Shipping overlapping segments would hand two nodes the same virtual
117
+ // IP across the federation. Loud on stderr rather than thrown: a bad
118
+ // data file must not stop an existing install from starting.
119
+ for (const c of findDoraSegmentOverlaps(doras)) {
120
+ console.error(`[dora] SEGMENT OVERLAP: ${c.a} (${c.segmentA}) and ${c.b} (${c.segmentB}) ` +
121
+ `allocate from the same addresses — they will hand out duplicate virtual IPs.`);
122
+ }
63
123
  return doras;
124
+ }
64
125
  }
65
126
  catch {
66
127
  // missing/unreadable/malformed — use the fallback below
@@ -692,19 +692,57 @@ var DEFAULT_EXPRESS_NODES = [
692
692
  { host: "tokyo.fi.chat", port: 8443, pk: "EzpBtoUkjeMQfuLGWwTUbvzCn9rK4J648Ziy21EKxefo" }
693
693
  ];
694
694
  var DEFAULT_DORAS_FALLBACK = [
695
- { name: "dora-beagle", userid: "AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN", address: "NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM" },
696
- // 10.86.64.10–127.254
697
- { name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK" },
698
- // 10.86.128.10–191.254
699
- { name: "dora-tokyo", userid: "AB6BZfbrTFWw9eUoVpHdJqhhRnY8bTttp4CHTZ2Xfzxi", address: "MAW2eBqBuQ6SmaXTrnZRRayQjAj3aLatwPy4xmBp7spnJeV569op" }
700
- // 10.86.192.10–254.254
695
+ { name: "dora-beagle", userid: "AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN", address: "NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM", segment: "10.86.64.10-10.86.127.254" },
696
+ { name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK", segment: "10.86.128.10-10.86.191.254" },
697
+ { name: "dora-tokyo", userid: "AB6BZfbrTFWw9eUoVpHdJqhhRnY8bTttp4CHTZ2Xfzxi", address: "MAW2eBqBuQ6SmaXTrnZRRayQjAj3aLatwPy4xmBp7spnJeV569op", segment: "10.86.192.10-10.86.254.254" }
701
698
  ];
699
+ function ipToNum(ip) {
700
+ const parts = ip.trim().split(".");
701
+ if (parts.length !== 4) return null;
702
+ let n = 0;
703
+ for (const p of parts) {
704
+ const v = Number(p);
705
+ if (!Number.isInteger(v) || v < 0 || v > 255) return null;
706
+ n = n * 256 + v;
707
+ }
708
+ return n;
709
+ }
710
+ function parseDoraSegment(segment) {
711
+ if (!segment) return null;
712
+ const [a, b] = segment.split("-");
713
+ if (!a || !b) return null;
714
+ const start = ipToNum(a);
715
+ const end = ipToNum(b);
716
+ if (start === null || end === null || start > end) return null;
717
+ return { start, end };
718
+ }
719
+ function findDoraSegmentOverlaps(doras) {
720
+ const parsed = doras.map((d) => ({ name: d.name, segment: d.segment, range: parseDoraSegment(d.segment) })).filter((d) => !!d.range);
721
+ const clashes = [];
722
+ for (let i = 0; i < parsed.length; i++) {
723
+ for (let j = i + 1; j < parsed.length; j++) {
724
+ const x = parsed[i];
725
+ const y = parsed[j];
726
+ if (x.range.start <= y.range.end && y.range.start <= x.range.end) {
727
+ clashes.push({ a: x.name, b: y.name, segmentA: x.segment, segmentB: y.segment });
728
+ }
729
+ }
730
+ }
731
+ return clashes;
732
+ }
702
733
  function loadDefaultDoras() {
703
734
  try {
704
735
  const file = resolve(dirname(new URL(import.meta.url).pathname), "../../config/default-doras.yaml");
705
736
  const parsed = yaml.load(readFileSync(file, "utf-8"));
706
737
  const doras = (parsed?.doras ?? []).filter((d) => d && d.userid && d.address);
707
- if (doras.length > 0) return doras;
738
+ if (doras.length > 0) {
739
+ for (const c of findDoraSegmentOverlaps(doras)) {
740
+ console.error(
741
+ `[dora] SEGMENT OVERLAP: ${c.a} (${c.segmentA}) and ${c.b} (${c.segmentB}) allocate from the same addresses \u2014 they will hand out duplicate virtual IPs.`
742
+ );
743
+ }
744
+ return doras;
745
+ }
708
746
  } catch {
709
747
  }
710
748
  return DEFAULT_DORAS_FALLBACK;
@@ -63,6 +63,10 @@ export declare class DaemonServer {
63
63
  private isRunning;
64
64
  private tunRebuildInProgress;
65
65
  private lastTunRebuildMs;
66
+ /** Devices whose close was DELIBERATE (IP swap / rebuild). Their "closed"
67
+ * event must not trigger crash-recovery — that race spawned a second
68
+ * helper holding the same address (two utuns, half the traffic lost). */
69
+ private retiredTuns;
66
70
  private statusTimer?;
67
71
  private pidFile?;
68
72
  private configDir;
@@ -109,6 +109,10 @@ export class DaemonServer {
109
109
  isRunning = false;
110
110
  tunRebuildInProgress = false;
111
111
  lastTunRebuildMs = 0;
112
+ /** Devices whose close was DELIBERATE (IP swap / rebuild). Their "closed"
113
+ * event must not trigger crash-recovery — that race spawned a second
114
+ * helper holding the same address (two utuns, half the traffic lost). */
115
+ retiredTuns = new WeakSet();
112
116
  statusTimer;
113
117
  pidFile;
114
118
  configDir;
@@ -137,6 +141,9 @@ export class DaemonServer {
137
141
  this.logger.info(`Reconfiguring TUN: allocated IP changed ${currentIp} -> ${newIp}`);
138
142
  const ifname = this.tunDevice.getInterface();
139
143
  try {
144
+ // Mark this close as intentional so attachTunRecovery doesn't treat it
145
+ // as a crash and spawn a competing helper (see attachTunRecovery).
146
+ this.retiredTuns.add(this.tunDevice);
140
147
  await this.tunDevice.close();
141
148
  await this.routeManager.cleanup(ifname, this.config.network.subnet, currentIp);
142
149
  this.tunDevice = new TunDevice({
@@ -174,6 +181,15 @@ export class DaemonServer {
174
181
  device.once("closed", () => {
175
182
  if (!this.isRunning || this.useMockTun)
176
183
  return;
184
+ // An INTENTIONAL close (reconfigureTunTo swapping to a dora-assigned IP,
185
+ // or rebuildTun retiring us) must not trigger recovery. Without this
186
+ // guard the two paths race: reconfigure closes the old device, recovery
187
+ // sees "closed" and rebuilds a SECOND helper one second after
188
+ // reconfigure opened its own — two utun devices then hold the same
189
+ // address, the kernel routes into whichever won, and half the traffic
190
+ // (observed: ICMP, while TCP survived) vanishes into the orphan.
191
+ if (this.retiredTuns.has(device))
192
+ return;
177
193
  const delay = Date.now() - this.lastTunRebuildMs < 10_000 ? 5_000 : 1_000;
178
194
  this.logger.warn(`TUN closed unexpectedly — rebuilding data plane in ${delay}ms`);
179
195
  setTimeout(() => void this.rebuildTun(), delay);
@@ -187,6 +203,17 @@ export class DaemonServer {
187
203
  this.lastTunRebuildMs = Date.now();
188
204
  try {
189
205
  const ip = this.tunDevice.getConfig().ip;
206
+ // Retire the device we're replacing BEFORE opening its successor. On a
207
+ // genuine helper crash this is a no-op; in any other path it kills the
208
+ // old helper process, which otherwise lives on holding a second utun
209
+ // with our address and steals routed traffic from the new device.
210
+ this.retiredTuns.add(this.tunDevice);
211
+ try {
212
+ await this.tunDevice.close();
213
+ }
214
+ catch {
215
+ // already dead — the common case for a real crash
216
+ }
190
217
  const dev = new TunDevice({
191
218
  config: { name: this.config.network.interface, ip, subnet: this.config.network.subnet },
192
219
  mockMode: false,
@@ -408,6 +435,10 @@ export class DaemonServer {
408
435
  const realName = f.name && f.name !== "@decentnetwork/peer" ? f.name : undefined;
409
436
  return {
410
437
  userid: uid,
438
+ // Full Carrier address (pubkey+nospam+checksum) — the form a NEW
439
+ // node needs to friend this peer. userid alone can't be added,
440
+ // so the UI surfaces this for "recommend a friend to a friend".
441
+ address: f.address,
411
442
  alias: meta?.alias,
412
443
  name: meta?.alias || realName || uid,
413
444
  status: f.status,
@@ -954,6 +985,11 @@ export class DaemonServer {
954
985
  // hands us a different IP, the backgrounded bootstrap below swaps the
955
986
  // TUN via reconfigureTunTo.
956
987
  this.routeManager = new RouteManager();
988
+ // A previous instance that was killed rather than stopped leaves its
989
+ // virtual address aliased on lo0. dora usually hands us a DIFFERENT
990
+ // address now, and the stale one keeps answering TCP over loopback while
991
+ // ICMP to it black-holes — a contradiction that reads as a broken tunnel.
992
+ await this.routeManager.sweepStaleAddresses(this.config.network.subnet, tunIp);
957
993
  this.tunDevice = new TunDevice({
958
994
  config: {
959
995
  name: this.config.network.interface,
@@ -82,6 +82,26 @@ export declare class DoraIntegration {
82
82
  * stored it in the friend record when the original (failed) request
83
83
  * went out, so we don't need it threaded through config.
84
84
  */
85
+ /**
86
+ * Make sure a friend-request has gone out to EVERY configured dora, so the
87
+ * node connects to all of them (and thus reads the full federated roster),
88
+ * not just the subset it was already friends with. This is the fix for the
89
+ * dora-world split: a node that lists dora A + B but is only friends with A
90
+ * reads only A's segment and can't resolve peers registered with B.
91
+ *
92
+ * Address resolution order for a userid:
93
+ * 1. an existing friend record (persisted address, for a dora that was
94
+ * friended before — e.g. a private dora added via `friend-request`),
95
+ * 2. the baked-in DEFAULT_DORAS address (beagle/sh/tokyo).
96
+ * A configured dora that is neither a known friend nor a default has no
97
+ * address form and cannot be auto-friended from its userid alone — the
98
+ * operator must `agentnet friend-request <address>` it once; after that its
99
+ * address persists in the friend record and this method keeps it healed.
100
+ *
101
+ * Idempotent: skips doras already online (never re-handshakes a healthy
102
+ * session); a re-sent request to an offline/requested dora just re-asserts.
103
+ */
104
+ private ensureDoraFriendships;
85
105
  private resendStuckFriendRequests;
86
106
  private scheduleBootstrapRetry;
87
107
  /**
@@ -12,6 +12,7 @@
12
12
  * peers need to manually share ipam.yaml entries.
13
13
  */
14
14
  import { DoraClient, AllRegistriesUnavailableError } from "@decentnetwork/dora";
15
+ import { DEFAULT_DORAS } from "../config/loader.js";
15
16
  import { Logger } from "../utils/logger.js";
16
17
  export class DoraIntegration {
17
18
  opts;
@@ -66,6 +67,15 @@ export class DoraIntegration {
66
67
  this.logger.warn("dora.userids empty — skipping dora registration");
67
68
  return this.opts.preferredIp ?? "";
68
69
  }
70
+ // Ensure we have a FRIENDSHIP to every configured dora — not just the
71
+ // one that happened to get friended at `agentnet init`. Without this a
72
+ // node silently connects to only a SUBSET of its dora.userids (whichever
73
+ // it was already friends with), so it reads only that dora's slice of the
74
+ // federation and can't resolve/ping peers registered with a sibling dora
75
+ // (the "friend online, chat works, but ping fails / no IP" split). Runs on
76
+ // every start + bootstrap retry until registered; skips already-online
77
+ // doras, so it never spams a healthy session.
78
+ this.ensureDoraFriendships();
69
79
  // Kick session establishment with each registry so the first call
70
80
  // doesn't fail on cold-start cookie/handshake delay. peer-manager
71
81
  // swallows the "friend offline" error internally.
@@ -216,6 +226,62 @@ export class DoraIntegration {
216
226
  * stored it in the friend record when the original (failed) request
217
227
  * went out, so we don't need it threaded through config.
218
228
  */
229
+ /**
230
+ * Make sure a friend-request has gone out to EVERY configured dora, so the
231
+ * node connects to all of them (and thus reads the full federated roster),
232
+ * not just the subset it was already friends with. This is the fix for the
233
+ * dora-world split: a node that lists dora A + B but is only friends with A
234
+ * reads only A's segment and can't resolve peers registered with B.
235
+ *
236
+ * Address resolution order for a userid:
237
+ * 1. an existing friend record (persisted address, for a dora that was
238
+ * friended before — e.g. a private dora added via `friend-request`),
239
+ * 2. the baked-in DEFAULT_DORAS address (beagle/sh/tokyo).
240
+ * A configured dora that is neither a known friend nor a default has no
241
+ * address form and cannot be auto-friended from its userid alone — the
242
+ * operator must `agentnet friend-request <address>` it once; after that its
243
+ * address persists in the friend record and this method keeps it healed.
244
+ *
245
+ * Idempotent: skips doras already online (never re-handshakes a healthy
246
+ * session); a re-sent request to an offline/requested dora just re-asserts.
247
+ */
248
+ ensureDoraFriendships() {
249
+ const userids = this.opts.config.userids ?? [];
250
+ if (userids.length === 0)
251
+ return;
252
+ const peer = this.opts.peerManager;
253
+ let friends = [];
254
+ try {
255
+ friends = peer.getFriends();
256
+ }
257
+ catch {
258
+ return;
259
+ }
260
+ const byId = new Map();
261
+ for (const f of friends) {
262
+ if (f.userid)
263
+ byId.set(f.userid, f);
264
+ if (f.pubkey)
265
+ byId.set(f.pubkey, f);
266
+ }
267
+ const defaultAddr = new Map(DEFAULT_DORAS.map((d) => [d.userid, d.address]));
268
+ for (const id of userids) {
269
+ const f = byId.get(id);
270
+ if (f && f.status === "online")
271
+ continue; // already connected — leave it
272
+ const address = f?.address ?? defaultAddr.get(id);
273
+ if (!address) {
274
+ this.logger.debug(`dora ${id.slice(0, 12)}…: no known address (not a default dora, not yet a friend) — run 'agentnet friend-request <address>' once to connect it`);
275
+ continue;
276
+ }
277
+ this.logger.info(`Ensuring dora friendship: ${id.slice(0, 12)}… (status=${f?.status ?? "not-a-friend"})`);
278
+ peer
279
+ .sendFriendRequest(address, `decentlan: connect all doras (${this.opts.nodeName})`)
280
+ .catch((err) => {
281
+ this.logger.debug(`ensureDoraFriendship ${id.slice(0, 12)}: ${err instanceof Error ? err.message : err}`);
282
+ });
283
+ }
284
+ }
219
285
  resendStuckFriendRequests() {
220
286
  const userids = this.opts.config.userids ?? [];
221
287
  const peer = this.opts.peerManager;
@@ -38,6 +38,22 @@ export declare class RouteManager {
38
38
  * configureTun; without that, the alias persists across daemon
39
39
  * restarts (harmless, but leaks state).
40
40
  */
41
+ /**
42
+ * Drop leftover virtual addresses from a PREVIOUS daemon instance.
43
+ *
44
+ * `cleanup()` only runs on a graceful shutdown. Kill the daemon — `service
45
+ * uninstall`, SIGKILL, a crash — and its lo0 alias plus host route for the
46
+ * old address survive. dora hands out a different address on the next
47
+ * registration, so the host ends up answering on BOTH: the dead address
48
+ * still completes TCP connections via loopback while ICMP to it goes
49
+ * nowhere. That contradiction ("telnet works but ping doesn't") is what
50
+ * makes the leak so expensive to diagnose — it reads as a broken tunnel.
51
+ *
52
+ * Called at startup with the address we're about to use; everything else in
53
+ * our subnet is a leftover and goes. Best-effort throughout: a host with no
54
+ * leftovers must still start normally.
55
+ */
56
+ sweepStaleAddresses(subnet: string, keepIp: string): Promise<void>;
41
57
  cleanup(name: string, subnet: string, ip?: string): Promise<void>;
42
58
  /**
43
59
  * Enable IP forwarding (Linux only)
@@ -225,6 +225,56 @@ export class RouteManager {
225
225
  * configureTun; without that, the alias persists across daemon
226
226
  * restarts (harmless, but leaks state).
227
227
  */
228
+ /**
229
+ * Drop leftover virtual addresses from a PREVIOUS daemon instance.
230
+ *
231
+ * `cleanup()` only runs on a graceful shutdown. Kill the daemon — `service
232
+ * uninstall`, SIGKILL, a crash — and its lo0 alias plus host route for the
233
+ * old address survive. dora hands out a different address on the next
234
+ * registration, so the host ends up answering on BOTH: the dead address
235
+ * still completes TCP connections via loopback while ICMP to it goes
236
+ * nowhere. That contradiction ("telnet works but ping doesn't") is what
237
+ * makes the leak so expensive to diagnose — it reads as a broken tunnel.
238
+ *
239
+ * Called at startup with the address we're about to use; everything else in
240
+ * our subnet is a leftover and goes. Best-effort throughout: a host with no
241
+ * leftovers must still start normally.
242
+ */
243
+ async sweepStaleAddresses(subnet, keepIp) {
244
+ if (this.platform !== "darwin")
245
+ return; // lo0-alias leak is macOS-specific
246
+ const prefix = `${this.subnetBase(subnet).split(".").slice(0, 2).join(".")}.`;
247
+ let routes = "";
248
+ try {
249
+ routes = this.commandRunner("netstat -rn -f inet");
250
+ }
251
+ catch {
252
+ return; // can't enumerate — nothing safe to do
253
+ }
254
+ const stale = new Set();
255
+ for (const line of routes.split("\n")) {
256
+ // Host routes we install look like: "10.86.64.11 127.0.0.1 UGHS lo0"
257
+ const m = /^(\d+\.\d+\.\d+\.\d+)\s+127\.0\.0\.1\s+\S*H\S*\s+lo0/.exec(line.trim());
258
+ const addr = m?.[1];
259
+ if (addr && addr.startsWith(prefix) && addr !== keepIp)
260
+ stale.add(addr);
261
+ }
262
+ for (const addr of stale) {
263
+ try {
264
+ this.exec(`route -n delete -host ${addr}`);
265
+ }
266
+ catch {
267
+ // already gone
268
+ }
269
+ try {
270
+ this.exec(`ifconfig lo0 -alias ${addr}`);
271
+ }
272
+ catch {
273
+ // not aliased
274
+ }
275
+ this.logger.info(`Removed stale virtual address ${addr} left by a previous daemon`);
276
+ }
277
+ }
228
278
  async cleanup(name, subnet, ip) {
229
279
  try {
230
280
  this.logger.info(`Cleaning up TUN and routes`);
@@ -15,6 +15,9 @@ const ICON_PATHS = {
15
15
  check: '<path d="M20 6 9 17l-5-5"/>',
16
16
  checkCheck: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.6 7.6L13 16"/>',
17
17
  copy: '<rect x="9" y="9" width="12" height="12" rx="2.4"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/>',
18
+ // Three linked nodes — an address is for passing ON to someone else, so it
19
+ // must not wear the same glyph as the userid's plain copy.
20
+ share: '<circle cx="18" cy="5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="19" r="2.6"/><path d="M8.3 10.8l7.4-4.3M8.3 13.2l7.4 4.3"/>',
18
21
  refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.3"/><path d="M21 4v4.5h-4.5"/>',
19
22
  clock: '<circle cx="12" cy="12" r="9.5"/><path d="M12 7.5V12l3 2"/>',
20
23
  edit: '<path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/>',
@@ -500,7 +503,7 @@ function StatusDot({ online, size = 8 }) {
500
503
  boxShadow: online ? "0 0 0 3px color-mix(in oklab, var(--online), transparent 80%)" : "none"
501
504
  } });
502
505
  }
503
- function Mono({ children, dim, size = 12.5, copy, title }) {
506
+ function Mono({ children, dim, size = 12.5, copy, title, icon = "copy" }) {
504
507
  const [hit, setHit] = React.useState(false);
505
508
  const onCopy = (e) => {
506
509
  e.stopPropagation();
@@ -518,7 +521,7 @@ function Mono({ children, dim, size = 12.5, copy, title }) {
518
521
  alignItems: "center",
519
522
  gap: 5,
520
523
  whiteSpace: "nowrap"
521
- } }, children, copy && /* @__PURE__ */ React.createElement(Icon, { name: hit ? "check" : "copy", size: 12, stroke: 2, color: hit ? "var(--online)" : "var(--faint)" }));
524
+ } }, children, copy && /* @__PURE__ */ React.createElement(Icon, { name: hit ? "check" : icon, size: 12, stroke: 2, color: hit ? "var(--online)" : "var(--faint)" }));
522
525
  }
523
526
  function shortKey(s, head = 6, tail = 5) {
524
527
  if (!s || s.length <= head + tail + 1) return s;
@@ -1210,6 +1213,98 @@ function dkCallFromText(text) {
1210
1213
  const m = /^WebRTC (audio|video) call: (incoming|outgoing)$/.exec(String(text || ""));
1211
1214
  return m ? { kind: m[1], direction: m[2] } : null;
1212
1215
  }
1216
+ function dkHtmlEscape(s) {
1217
+ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1218
+ }
1219
+ function dkSafeHref(s) {
1220
+ const href = String(s || "").trim();
1221
+ if (!href) return "";
1222
+ try {
1223
+ const u = new URL(href, window.location.href);
1224
+ if (u.protocol === "http:" || u.protocol === "https:" || u.protocol === "mailto:") return u.href;
1225
+ } catch (e) {
1226
+ }
1227
+ return "";
1228
+ }
1229
+ function dkInlineMarkdown(s) {
1230
+ const text = String(s == null ? "" : s);
1231
+ const tokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\([^) \n]+(?: [^)]+)?\)|\*\*[^*\n]+?\*\*|\*[^*\n]+?\*)/g;
1232
+ let out = "";
1233
+ let last = 0;
1234
+ let m;
1235
+ while (m = tokenRe.exec(text)) {
1236
+ out += dkHtmlEscape(text.slice(last, m.index));
1237
+ const tok = m[0];
1238
+ if (tok[0] === "`") {
1239
+ out += "<code>" + dkHtmlEscape(tok.slice(1, -1)) + "</code>";
1240
+ } else if (tok.startsWith("[")) {
1241
+ const lm = /^\[([^\]\n]+)\]\(([^) \n]+)(?: [^)]+)?\)$/.exec(tok);
1242
+ const href = lm && dkSafeHref(lm[2]);
1243
+ out += href ? '<a href="' + dkHtmlEscape(href) + '" target="_blank" rel="noopener">' + dkHtmlEscape(lm[1]) + "</a>" : dkHtmlEscape(tok);
1244
+ } else if (tok.startsWith("**")) {
1245
+ out += "<strong>" + dkHtmlEscape(tok.slice(2, -2)) + "</strong>";
1246
+ } else if (tok.startsWith("*")) {
1247
+ out += "<em>" + dkHtmlEscape(tok.slice(1, -1)) + "</em>";
1248
+ } else {
1249
+ out += dkHtmlEscape(tok);
1250
+ }
1251
+ last = tokenRe.lastIndex;
1252
+ }
1253
+ out += dkHtmlEscape(text.slice(last));
1254
+ return out;
1255
+ }
1256
+ function dkMarkdownHtml(src) {
1257
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1258
+ let html = "";
1259
+ let i = 0;
1260
+ const isBlockStart = (line) => /^```/.test(line) || /^#{1,6}\s+/.test(line) || /^\s*[-*]\s+/.test(line) || /^\s*\d+\.\s+/.test(line);
1261
+ while (i < lines.length) {
1262
+ const line = lines[i];
1263
+ if (!line.trim()) {
1264
+ i += 1;
1265
+ continue;
1266
+ }
1267
+ if (/^```/.test(line)) {
1268
+ i += 1;
1269
+ const code = [];
1270
+ while (i < lines.length && !/^```/.test(lines[i])) {
1271
+ code.push(lines[i]);
1272
+ i += 1;
1273
+ }
1274
+ if (i < lines.length) i += 1;
1275
+ html += "<pre><code>" + dkHtmlEscape(code.join("\n")) + "</code></pre>";
1276
+ continue;
1277
+ }
1278
+ const hm = /^(#{1,6})\s+(.+)$/.exec(line);
1279
+ if (hm) {
1280
+ const level = Math.min(6, hm[1].length);
1281
+ html += "<h" + level + ">" + dkInlineMarkdown(hm[2]) + "</h" + level + ">";
1282
+ i += 1;
1283
+ continue;
1284
+ }
1285
+ if (/^\s*[-*]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
1286
+ const ordered = /^\s*\d+\.\s+/.test(line);
1287
+ html += ordered ? "<ol>" : "<ul>";
1288
+ while (i < lines.length && (ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*]\s+/.test(lines[i]))) {
1289
+ html += "<li>" + dkInlineMarkdown(lines[i].replace(ordered ? /^\s*\d+\.\s+/ : /^\s*[-*]\s+/, "")) + "</li>";
1290
+ i += 1;
1291
+ }
1292
+ html += ordered ? "</ol>" : "</ul>";
1293
+ continue;
1294
+ }
1295
+ const para = [line];
1296
+ i += 1;
1297
+ while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) {
1298
+ para.push(lines[i]);
1299
+ i += 1;
1300
+ }
1301
+ html += "<p>" + para.map(dkInlineMarkdown).join("<br>") + "</p>";
1302
+ }
1303
+ return html || dkHtmlEscape(src);
1304
+ }
1305
+ function MarkdownText({ text }) {
1306
+ return /* @__PURE__ */ React.createElement("div", { className: "dk-md", dangerouslySetInnerHTML: { __html: dkMarkdownHtml(text) } });
1307
+ }
1213
1308
  function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onCall, selMode, selected, onToggleSel, busy }) {
1214
1309
  const mine = m.from === "me";
1215
1310
  const rtcFile = !mine && !m.file ? dkRtcFileFromText(peer.userId || peer.id, m.text) : null;
@@ -1430,7 +1525,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onCall, selMo
1430
1525
  lineHeight: 1.4,
1431
1526
  letterSpacing: -0.1,
1432
1527
  wordBreak: "break-word"
1433
- } }, m.text), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), !mine && m.via && /* @__PURE__ */ React.createElement(
1528
+ } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: m.text })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), !mine && m.via && /* @__PURE__ */ React.createElement(
1434
1529
  "span",
1435
1530
  {
1436
1531
  title: m.via === "offline" ? "delivered via express relay (offline)" : "delivered over a live session (online)",
@@ -1668,7 +1763,18 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1668
1763
  onDrop
1669
1764
  },
1670
1765
  dragOver && /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", inset: 0, zIndex: 30, background: "rgba(91,140,255,0.10)", border: "2px dashed var(--accent)", borderRadius: 12, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", fontFamily: "var(--mono)", fontSize: 14, color: "var(--accent)" } }, lang === "zh" ? "\u677E\u5F00\u53D1\u9001\u6587\u4EF6" : "Drop to send file"),
1671
- /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), onSendRtcFile && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
1766
+ /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), title: T && T.openNet || "Network view", style: { background: "none", border: "none", cursor: "pointer", padding: 0, color: "var(--accent)", display: "inline-flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 })), /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.ip, title: peer.ip }, peer.ip), peer.address && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement(
1767
+ Mono,
1768
+ {
1769
+ size: 11.5,
1770
+ dim: true,
1771
+ icon: "share",
1772
+ copy: peer.address,
1773
+ title: `${T && T.copyAddr || "Copy address \u2014 share it so someone else can add this friend"}
1774
+ ${peer.address}`
1775
+ },
1776
+ shortKey(peer.address, 8, 6)
1777
+ )))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), onSendRtcFile && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
1672
1778
  "input",
1673
1779
  {
1674
1780
  ref: rtcFileRef,
@@ -2909,7 +3015,7 @@ function DkApp() {
2909
3015
  { id: "network", icon: "network", label: T.network },
2910
3016
  { id: "profile", icon: "userRound", label: T.profile }
2911
3017
  ];
2912
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId) }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
3018
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId) }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
2913
3019
  TweakRadio,
2914
3020
  {
2915
3021
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
@@ -3,7 +3,8 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Beagle</title>
6
+ <title>Beagle Chat</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='5' fill='%230d1117'/%3E%3Cg fill='none' stroke='%233fb950' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 17 6-6-6-6'/%3E%3Cpath d='M12 18.5h7.5'/%3E%3C/g%3E%3C/svg%3E" />
7
8
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
8
9
  <style>
9
10
  * { box-sizing: border-box; }
@@ -22,6 +23,20 @@
22
23
  ::-webkit-scrollbar { width: 9px; height: 9px; }
23
24
  ::-webkit-scrollbar-thumb { background: var(--line); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
24
25
  ::-webkit-scrollbar-track { background: transparent; }
26
+ .dk-md { white-space: normal; }
27
+ .dk-md p { margin: 0; }
28
+ .dk-md p + p, .dk-md p + ul, .dk-md p + ol, .dk-md p + pre,
29
+ .dk-md ul + p, .dk-md ol + p, .dk-md pre + p,
30
+ .dk-md h1 + p, .dk-md h2 + p, .dk-md h3 + p { margin-top: 0.5em; }
31
+ .dk-md h1, .dk-md h2, .dk-md h3, .dk-md h4, .dk-md h5, .dk-md h6 {
32
+ margin: 0 0 0.35em; font-size: 1em; line-height: 1.25; font-weight: 800;
33
+ }
34
+ .dk-md ul, .dk-md ol { margin: 0.25em 0; padding-left: 1.35em; }
35
+ .dk-md li { margin: 0.15em 0; }
36
+ .dk-md code { font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 0.92em; background: rgba(0,0,0,0.18); border-radius: 4px; padding: 0.08em 0.3em; }
37
+ .dk-md pre { margin: 0.45em 0 0; padding: 8px 10px; overflow: auto; border-radius: 8px; background: rgba(0,0,0,0.22); }
38
+ .dk-md pre code { display: block; background: transparent; padding: 0; white-space: pre; }
39
+ .dk-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
25
40
  @keyframes dkpulse { 0% { transform: scale(0.9); opacity: 0.7; } 70% { transform: scale(1.5); opacity: 0; } 100% { opacity: 0; } }
26
41
  </style>
27
42
  </head>
package/dist/ui/server.js CHANGED
@@ -574,6 +574,9 @@ export function startFriendUi(opts) {
574
574
  id: uid,
575
575
  alias: f.alias || realName || null,
576
576
  userId: uid,
577
+ // Shareable Carrier address so a friend can be recommended to
578
+ // another friend (userid only works once already friends).
579
+ address: f.address || "",
577
580
  online,
578
581
  via: online ? viaFromTransport(sess?.transport) : null,
579
582
  ping: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.229",
3
+ "version": "0.1.231",
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",