@decentnetwork/lan 0.1.230 → 0.1.232
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/dist/cli/commands.js +18 -4
- package/dist/config/loader.d.ts +27 -0
- package/dist/config/loader.js +65 -4
- package/dist/console/console.js +45 -7
- package/dist/daemon/server.d.ts +4 -0
- package/dist/daemon/server.js +36 -0
- package/dist/dora/dora-integration.d.ts +20 -0
- package/dist/dora/dora-integration.js +66 -0
- package/dist/tun/route-manager.d.ts +16 -0
- package/dist/tun/route-manager.js +50 -0
- package/dist/ui/desktop/app.js +20 -11
- package/dist/ui/desktop/index.html +2 -1
- package/dist/ui/server.js +3 -0
- package/package.json +1 -1
package/dist/cli/commands.js
CHANGED
|
@@ -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(`
|
|
388
|
-
console.log(`
|
|
389
|
-
console.log(`
|
|
390
|
-
console.log(`
|
|
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}`);
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/loader.js
CHANGED
|
@@ -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"
|
|
51
|
-
{ name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK"
|
|
52
|
-
{ name: "dora-tokyo", userid: "AB6BZfbrTFWw9eUoVpHdJqhhRnY8bTttp4CHTZ2Xfzxi", address: "MAW2eBqBuQ6SmaXTrnZRRayQjAj3aLatwPy4xmBp7spnJeV569op"
|
|
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
|
package/dist/console/console.js
CHANGED
|
@@ -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
|
-
|
|
697
|
-
{ name: "dora-
|
|
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)
|
|
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;
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/daemon/server.js
CHANGED
|
@@ -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`);
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -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" :
|
|
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;
|
|
@@ -1760,7 +1763,18 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
|
|
|
1760
1763
|
onDrop
|
|
1761
1764
|
},
|
|
1762
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"),
|
|
1763
|
-
/* @__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,
|
|
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(
|
|
1764
1778
|
"input",
|
|
1765
1779
|
{
|
|
1766
1780
|
ref: rtcFileRef,
|
|
@@ -2958,14 +2972,9 @@ function DkApp() {
|
|
|
2958
2972
|
if (!activeId || !text || !text.trim()) return;
|
|
2959
2973
|
dkApi.send(activeId, text.trim()).then(() => data.loadThread(activeId)).then(data.refresh);
|
|
2960
2974
|
};
|
|
2961
|
-
const onSendFile = (file
|
|
2975
|
+
const onSendFile = (file) => {
|
|
2962
2976
|
if (!activeId || !file) return Promise.resolve({ ok: false });
|
|
2963
|
-
return
|
|
2964
|
-
if (r && r.ok && r.via === "webrtc") {
|
|
2965
|
-
return dkApi.send(activeId, (t.lang === "zh" ? "\u5DF2\u901A\u8FC7 WebRTC \u53D1\u9001\u6587\u4EF6: " : "sent file via WebRTC: ") + file.name).then(() => ({ ok: true, via: "webrtc" }));
|
|
2966
|
-
}
|
|
2967
|
-
return dkApi.sendFile(activeId, file);
|
|
2968
|
-
}).then((r) => {
|
|
2977
|
+
return dkApi.sendFile(activeId, file).then((r) => {
|
|
2969
2978
|
data.loadThread(activeId);
|
|
2970
2979
|
data.refresh();
|
|
2971
2980
|
return r;
|
|
@@ -3001,7 +3010,7 @@ function DkApp() {
|
|
|
3001
3010
|
{ id: "network", icon: "network", label: T.network },
|
|
3002
3011
|
{ id: "profile", icon: "userRound", label: T.profile }
|
|
3003
3012
|
];
|
|
3004
|
-
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(
|
|
3013
|
+
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(
|
|
3005
3014
|
TweakRadio,
|
|
3006
3015
|
{
|
|
3007
3016
|
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; }
|
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.
|
|
3
|
+
"version": "0.1.232",
|
|
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",
|