@decentnetwork/lan 0.1.303 → 0.1.305
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.d.ts +7 -3
- package/dist/cli/commands.js +121 -57
- package/dist/cli/index.js +7 -2
- package/dist/daemon/ipc.d.ts +45 -0
- package/dist/daemon/ipc.js +92 -2
- package/dist/daemon/message-store.js +6 -0
- package/dist/ui/desktop/app.js +1 -1
- package/package.json +2 -2
package/dist/cli/commands.d.ts
CHANGED
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
* CLI command handlers
|
|
3
3
|
*/
|
|
4
4
|
import type { BootstrapNode } from "../types.js";
|
|
5
|
-
/**
|
|
6
|
-
* Initialize ~/.agentnet directory and config
|
|
7
|
-
*/
|
|
8
5
|
export declare function cmdInit(args: {
|
|
9
6
|
name?: string;
|
|
10
7
|
configDir?: string;
|
|
8
|
+
noDora?: boolean;
|
|
11
9
|
}): Promise<void>;
|
|
12
10
|
/**
|
|
13
11
|
* Show identity information.
|
|
@@ -97,6 +95,8 @@ export declare function cmdFriendRequest(args: {
|
|
|
97
95
|
* per-address ok/error so the caller can report which landed. If the
|
|
98
96
|
* daemon is already running, each request is routed via IPC (no peer).
|
|
99
97
|
*/
|
|
98
|
+
/** Reject when `deadlineAt` (epoch ms) passes first; a no-op for Infinity. */
|
|
99
|
+
export declare function raceDeadline<T>(p: Promise<T>, deadlineAt: number, what: string): Promise<T>;
|
|
100
100
|
export declare function cmdFriendRequestMany(args: {
|
|
101
101
|
targets: {
|
|
102
102
|
address: string;
|
|
@@ -105,6 +105,10 @@ export declare function cmdFriendRequestMany(args: {
|
|
|
105
105
|
hello?: string;
|
|
106
106
|
configDir?: string;
|
|
107
107
|
waitMs?: number;
|
|
108
|
+
/** Overall cap for the standalone-peer path. Without it, joinNetwork and each
|
|
109
|
+
* sendFriendRequest are unbounded — `init` was seen running past 2 minutes
|
|
110
|
+
* while holding the identity, which also blocks the service from starting. */
|
|
111
|
+
deadlineMs?: number;
|
|
108
112
|
}): Promise<{
|
|
109
113
|
label: string;
|
|
110
114
|
address: string;
|
package/dist/cli/commands.js
CHANGED
|
@@ -176,6 +176,10 @@ async function ipcCall(config, req, timeoutMs = 30_000) {
|
|
|
176
176
|
/**
|
|
177
177
|
* Initialize ~/.agentnet directory and config
|
|
178
178
|
*/
|
|
179
|
+
/** Cap on init's best-effort dora friending: a normal run (join, 15 s announce,
|
|
180
|
+
* send, 8 s delivery wait) fits; a stuck bootstrap no longer holds the
|
|
181
|
+
* identity for minutes. The daemon friends a dora itself on first start. */
|
|
182
|
+
const INIT_DORA_DEADLINE_MS = 45_000;
|
|
179
183
|
export async function cmdInit(args) {
|
|
180
184
|
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
181
185
|
const nodeName = args.name || `node-${Math.floor(Math.random() * 10000)}`;
|
|
@@ -209,7 +213,10 @@ export async function cmdInit(args) {
|
|
|
209
213
|
// them gives redundancy: any one staying up is enough to get an IP.
|
|
210
214
|
const configuredUserids = new Set(config.dora?.userids ?? []);
|
|
211
215
|
const dorasToFriend = DEFAULT_DORAS.filter((d) => configuredUserids.has(d.userid));
|
|
212
|
-
if (dorasToFriend.length > 0) {
|
|
216
|
+
if (dorasToFriend.length > 0 && args.noDora) {
|
|
217
|
+
console.log(`\nSkipping dora friend-requests (--no-dora); the daemon joins a dora itself on first start.`);
|
|
218
|
+
}
|
|
219
|
+
else if (dorasToFriend.length > 0) {
|
|
213
220
|
console.log(`\nFriending ${dorasToFriend.length} default dora${dorasToFriend.length > 1 ? "s" : ""} (${dorasToFriend
|
|
214
221
|
.map((d) => d.name)
|
|
215
222
|
.join(", ")}) in one Carrier session so the daemon can join the shared network on first start.`);
|
|
@@ -218,6 +225,7 @@ export async function cmdInit(args) {
|
|
|
218
225
|
targets: dorasToFriend.map((d) => ({ address: d.address, label: d.name })),
|
|
219
226
|
hello: `decentlan init (${nodeName})`,
|
|
220
227
|
waitMs: 8000,
|
|
228
|
+
deadlineMs: INIT_DORA_DEADLINE_MS,
|
|
221
229
|
configDir: dir,
|
|
222
230
|
});
|
|
223
231
|
const failed = results.filter((r) => !r.ok);
|
|
@@ -681,6 +689,17 @@ export async function cmdFriendRequest(args) {
|
|
|
681
689
|
* per-address ok/error so the caller can report which landed. If the
|
|
682
690
|
* daemon is already running, each request is routed via IPC (no peer).
|
|
683
691
|
*/
|
|
692
|
+
/** Reject when `deadlineAt` (epoch ms) passes first; a no-op for Infinity. */
|
|
693
|
+
export function raceDeadline(p, deadlineAt, what) {
|
|
694
|
+
const left = deadlineAt - Date.now();
|
|
695
|
+
if (!Number.isFinite(left))
|
|
696
|
+
return p;
|
|
697
|
+
let timer;
|
|
698
|
+
const timeout = new Promise((_, reject) => {
|
|
699
|
+
timer = setTimeout(() => reject(new Error(`timed out while ${what}`)), Math.max(0, left));
|
|
700
|
+
});
|
|
701
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
702
|
+
}
|
|
684
703
|
export async function cmdFriendRequestMany(args) {
|
|
685
704
|
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
686
705
|
const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
|
|
@@ -699,6 +718,7 @@ export async function cmdFriendRequestMany(args) {
|
|
|
699
718
|
// Daemon down → one standalone peer session for ALL requests.
|
|
700
719
|
const { Peer } = await import("@decentnetwork/peer");
|
|
701
720
|
const keyFile = resolve(config.carrier.dataDir, "keypair.json");
|
|
721
|
+
const deadlineAt = args.deadlineMs !== undefined ? Date.now() + args.deadlineMs : Infinity;
|
|
702
722
|
console.log(`Opening peer with identity at ${keyFile}...`);
|
|
703
723
|
const peer = await Peer.create({
|
|
704
724
|
keyFile,
|
|
@@ -706,37 +726,52 @@ export async function cmdFriendRequestMany(args) {
|
|
|
706
726
|
bootstrapNodes: config.carrier.bootstrapNodes,
|
|
707
727
|
expressNodes: config.carrier.expressNodes,
|
|
708
728
|
});
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
console.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
729
|
+
// The peer holds this identity until it stops; stop it on every exit path,
|
|
730
|
+
// including a missed deadline, or the process (and the identity) stays held.
|
|
731
|
+
try {
|
|
732
|
+
await raceDeadline(peer.start(), deadlineAt, "starting the peer");
|
|
733
|
+
console.log(`My address: ${peer.address()}`);
|
|
734
|
+
console.log(`My pubkey: ${peer.pubkey()}`);
|
|
735
|
+
console.log(`Joining Carrier network...`);
|
|
736
|
+
const joinResult = await raceDeadline(peer.joinNetwork(), deadlineAt, "joining the Carrier network");
|
|
737
|
+
console.log(`Joined via ${joinResult.respondingNode.host}:${joinResult.respondingNode.port}`);
|
|
738
|
+
console.log(`Announcing self (15s)...`);
|
|
739
|
+
await raceDeadline(peer.announceSelf(15000).catch((err) => {
|
|
740
|
+
console.warn(`Self-announce failed: ${err.message}`);
|
|
741
|
+
}), deadlineAt, "announcing");
|
|
742
|
+
// Fire all requests on the one announced session.
|
|
743
|
+
for (const t of args.targets) {
|
|
744
|
+
try {
|
|
745
|
+
console.log(`Sending friend request to ${t.label} (${t.address.slice(0, 16)}...)...`);
|
|
746
|
+
await raceDeadline(peer.sendFriendRequest(t.address, args.hello || "Decent AgentNet friend request"), deadlineAt, `sending to ${t.label}`);
|
|
747
|
+
results.push({ label: t.label, address: t.address, ok: true });
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
751
|
+
console.warn(` Failed to send to ${t.label}: ${msg}`);
|
|
752
|
+
results.push({ label: t.label, address: t.address, ok: false, error: msg });
|
|
753
|
+
}
|
|
725
754
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
755
|
+
// Single shared wait for relay delivery of all requests above.
|
|
756
|
+
const waitMs = Math.max(0, Math.min(args.waitMs ?? 8000, deadlineAt - Date.now()));
|
|
757
|
+
console.log(`Waiting ${waitMs}ms for relay delivery...`);
|
|
758
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
759
|
+
console.log(`\nFriend requests sent. Recipients running with autoAccept (the default) have already`);
|
|
760
|
+
console.log(`accepted — they can confirm with 'agentnet diag' and look for userid ${peer.userid()}.`);
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
// A deadline before the send loop (start/join/announce): nothing was sent.
|
|
764
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
765
|
+
console.warn(`Friend-requests abandoned: ${msg}`);
|
|
766
|
+
for (const t of args.targets) {
|
|
767
|
+
if (!results.some((r) => r.address === t.address)) {
|
|
768
|
+
results.push({ label: t.label, address: t.address, ok: false, error: msg });
|
|
769
|
+
}
|
|
730
770
|
}
|
|
731
771
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
736
|
-
const myUserid = peer.userid();
|
|
737
|
-
await peer.stop();
|
|
738
|
-
console.log(`\nFriend requests sent. Recipients running with autoAccept (the default) have already`);
|
|
739
|
-
console.log(`accepted — they can confirm with 'agentnet diag' and look for userid ${myUserid}.`);
|
|
772
|
+
finally {
|
|
773
|
+
await peer.stop().catch(() => { });
|
|
774
|
+
}
|
|
740
775
|
return results;
|
|
741
776
|
}
|
|
742
777
|
/**
|
|
@@ -2320,16 +2355,62 @@ export async function cmdDoraAutofriend(args) {
|
|
|
2320
2355
|
*/
|
|
2321
2356
|
export function windowsServiceLauncherScript(args) {
|
|
2322
2357
|
const psq = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
2358
|
+
// Supervise a daemon that crashes after running, but stop on one that cannot
|
|
2359
|
+
// start at all. The common case is the identity lock ("Another decentlan
|
|
2360
|
+
// daemon (pid N) is already running for this identity" — Beagle's embedded
|
|
2361
|
+
// peer holds it): retried every 5 s for ever, the loop ran invisibly for hours
|
|
2362
|
+
// while schtasks still reported the task Running. Five quick exits in a row
|
|
2363
|
+
// end the task with a non-zero code, so Last Result says something and the
|
|
2364
|
+
// log's last line says why. Starting the task again begins a fresh count.
|
|
2365
|
+
//
|
|
2366
|
+
// One encoding for the log: Windows PowerShell 5.1 writes `*>>` as UTF-16 but
|
|
2367
|
+
// Out-File -Encoding utf8 as UTF-8, and the old log interleaved both, so no
|
|
2368
|
+
// reader could show the give-up reason. Every writer is UTF-8 now; a log left
|
|
2369
|
+
// by an older launcher (UTF-16, BOM FF FE) is moved aside once rather than
|
|
2370
|
+
// having UTF-8 appended into it.
|
|
2323
2371
|
return `$ErrorActionPreference = 'Continue'
|
|
2324
2372
|
$env:AGENTNET_SERVICE_WRAPPER = '1'
|
|
2373
|
+
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
|
|
2374
|
+
$PSDefaultParameterValues['Out-File:Width'] = 4096
|
|
2375
|
+
$log = ${psq(args.logPath)}
|
|
2376
|
+
if (Test-Path $log) {
|
|
2377
|
+
$fs = [System.IO.File]::OpenRead($log); $h = New-Object byte[] 2; $n = $fs.Read($h, 0, 2); $fs.Close()
|
|
2378
|
+
if ($n -eq 2 -and $h[0] -eq 0xFF -and $h[1] -eq 0xFE) { Move-Item -Force $log "$log.utf16.old" }
|
|
2379
|
+
}
|
|
2380
|
+
$quickExits = 0
|
|
2325
2381
|
while ($true) {
|
|
2326
|
-
|
|
2382
|
+
$started = Get-Date
|
|
2383
|
+
& ${psq(args.nodeBin)} ${psq(args.cliEntry)} up --real-tun --config-dir ${psq(args.configDir)} *>> $log
|
|
2327
2384
|
$code = $LASTEXITCODE
|
|
2328
|
-
|
|
2385
|
+
$ran = [int]((Get-Date) - $started).TotalSeconds
|
|
2386
|
+
if ($ran -lt 30) { $quickExits++ } else { $quickExits = 0 }
|
|
2387
|
+
if ($quickExits -ge 5) {
|
|
2388
|
+
# PS 5.1 wraps native stderr as an ErrorRecord: skip its CategoryInfo lines, drop the "node.exe : " prefix.
|
|
2389
|
+
$why = Get-Content -Path $log -Tail 80 -ErrorAction SilentlyContinue |
|
|
2390
|
+
Where-Object { $_ -match 'already running|Error: ' -and $_ -notmatch 'CategoryInfo|FullyQualifiedErrorId' } |
|
|
2391
|
+
Select-Object -Last 1
|
|
2392
|
+
$why = ("$why" -replace '^node(\\.exe)? : ', '').Trim()
|
|
2393
|
+
"$(Get-Date -Format o) agentnet could not start ($quickExits exits within 30 s, last code $code); giving up. Last error: $why" | Out-File -FilePath $log -Append -Encoding utf8
|
|
2394
|
+
if ($code -eq 0) { $code = 1 }
|
|
2395
|
+
exit $code
|
|
2396
|
+
}
|
|
2397
|
+
"$(Get-Date -Format o) agentnet exited with code $code after $ran s; restarting in 5 seconds" | Out-File -FilePath $log -Append -Encoding utf8
|
|
2329
2398
|
Start-Sleep -Seconds 5
|
|
2330
2399
|
}
|
|
2331
2400
|
`;
|
|
2332
2401
|
}
|
|
2402
|
+
/**
|
|
2403
|
+
* The CLI entry of THIS install — what a service must run.
|
|
2404
|
+
*
|
|
2405
|
+
* Every platform's service installer runs as root/Administrator, so resolving
|
|
2406
|
+
* `agentnet` through PATH looks at root's PATH: it can name a missing binary,
|
|
2407
|
+
* or a different install of a different version (the dual-install trap:
|
|
2408
|
+
* `agentnet --version` says one thing, the service runs another). The
|
|
2409
|
+
* installer's own module file is the one version the user just asked to install.
|
|
2410
|
+
*/
|
|
2411
|
+
function serviceCliEntry() {
|
|
2412
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
2413
|
+
}
|
|
2333
2414
|
/**
|
|
2334
2415
|
* Key-only SSH for connections arriving over the AgentNet subnet.
|
|
2335
2416
|
*
|
|
@@ -2460,12 +2541,15 @@ export async function cmdServiceInstall(args) {
|
|
|
2460
2541
|
console.log(`Removed Windows startup task '${taskName}'.`);
|
|
2461
2542
|
return;
|
|
2462
2543
|
}
|
|
2463
|
-
const cliEntry =
|
|
2544
|
+
const cliEntry = serviceCliEntry();
|
|
2464
2545
|
const psq = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
2465
2546
|
const script = windowsServiceLauncherScript({ nodeBin, cliEntry, configDir: dir, logPath });
|
|
2466
2547
|
const fs = await import("fs/promises");
|
|
2467
2548
|
await fs.mkdir(dir, { recursive: true });
|
|
2468
|
-
|
|
2549
|
+
// With a BOM: the task runs powershell.exe (5.1), which reads a BOM-less
|
|
2550
|
+
// script in the ANSI code page, so a non-ASCII profile path (C:\Users\张伟\…)
|
|
2551
|
+
// would reach node mangled and the service could never start.
|
|
2552
|
+
await fs.writeFile(scriptPath, "\ufeff" + script, "utf-8");
|
|
2469
2553
|
const taskCommand = `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`;
|
|
2470
2554
|
const created = spawnSync("schtasks.exe", [
|
|
2471
2555
|
"/Create", "/F", "/TN", taskName, "/SC", "ONSTART", "/RU", "SYSTEM", "/RL", "HIGHEST", "/TR", taskCommand,
|
|
@@ -2497,19 +2581,7 @@ export async function cmdServiceInstall(args) {
|
|
|
2497
2581
|
}
|
|
2498
2582
|
return;
|
|
2499
2583
|
}
|
|
2500
|
-
|
|
2501
|
-
// invoked as `node …/index.js`, so a `which` lookup is more
|
|
2502
|
-
// portable. Fall back to /usr/local/bin/agentnet (npm-default
|
|
2503
|
-
// global prefix on most Linux setups) if the lookup fails.
|
|
2504
|
-
let agentnetBin;
|
|
2505
|
-
try {
|
|
2506
|
-
agentnetBin = execSync("command -v agentnet", { encoding: "utf-8" }).trim();
|
|
2507
|
-
if (!agentnetBin)
|
|
2508
|
-
throw new Error("not found");
|
|
2509
|
-
}
|
|
2510
|
-
catch {
|
|
2511
|
-
agentnetBin = "/usr/local/bin/agentnet";
|
|
2512
|
-
}
|
|
2584
|
+
const cliEntry = serviceCliEntry();
|
|
2513
2585
|
const unit = `[Unit]
|
|
2514
2586
|
Description=Decent AgentNet daemon
|
|
2515
2587
|
After=network-online.target
|
|
@@ -2519,7 +2591,7 @@ Wants=network-online.target
|
|
|
2519
2591
|
Type=simple
|
|
2520
2592
|
User=root
|
|
2521
2593
|
Environment=PATH=${servicePath}
|
|
2522
|
-
ExecStart=${nodeBin} ${
|
|
2594
|
+
ExecStart=${nodeBin} ${cliEntry} up --real-tun --config-dir ${dir}
|
|
2523
2595
|
Restart=on-failure
|
|
2524
2596
|
RestartSec=5
|
|
2525
2597
|
# Log to the journal so the intuitive 'journalctl -u agentnet -f' just works.
|
|
@@ -2569,15 +2641,7 @@ WantedBy=multi-user.target
|
|
|
2569
2641
|
}
|
|
2570
2642
|
return;
|
|
2571
2643
|
}
|
|
2572
|
-
|
|
2573
|
-
try {
|
|
2574
|
-
agentnetBin = execSync("command -v agentnet", { encoding: "utf-8" }).trim();
|
|
2575
|
-
if (!agentnetBin)
|
|
2576
|
-
throw new Error("not found");
|
|
2577
|
-
}
|
|
2578
|
-
catch {
|
|
2579
|
-
agentnetBin = "/usr/local/bin/agentnet";
|
|
2580
|
-
}
|
|
2644
|
+
const cliEntry = serviceCliEntry();
|
|
2581
2645
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2582
2646
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2583
2647
|
<plist version="1.0"><dict>
|
|
@@ -2585,7 +2649,7 @@ WantedBy=multi-user.target
|
|
|
2585
2649
|
<key>UserName</key><string>root</string>
|
|
2586
2650
|
<key>ProgramArguments</key><array>
|
|
2587
2651
|
<string>${nodeBin}</string>
|
|
2588
|
-
<string>${
|
|
2652
|
+
<string>${cliEntry}</string>
|
|
2589
2653
|
<string>up</string>
|
|
2590
2654
|
<string>--real-tun</string>
|
|
2591
2655
|
<string>--config-dir</string>
|
package/dist/cli/index.js
CHANGED
|
@@ -17,8 +17,13 @@ async function main() {
|
|
|
17
17
|
.usage("Usage: $0 <command> [options]")
|
|
18
18
|
.command("init", "Initialize ~/.agentnet directory", (y) => y
|
|
19
19
|
.option("name", { type: "string", describe: "Node name" })
|
|
20
|
-
.option("config-dir", { type: "string", describe: "Config directory" })
|
|
21
|
-
|
|
20
|
+
.option("config-dir", { type: "string", describe: "Config directory" })
|
|
21
|
+
.option("dora", {
|
|
22
|
+
type: "boolean",
|
|
23
|
+
default: true,
|
|
24
|
+
describe: "Friend the default doras now; --no-dora skips it (the daemon does it on first start) — use when 'up' runs next",
|
|
25
|
+
}), async (argv) => {
|
|
26
|
+
await cmdInit({ name: argv.name, configDir: argv["config-dir"], noDora: argv.dora === false });
|
|
22
27
|
})
|
|
23
28
|
.command("identity show", "Display Carrier identity", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
|
|
24
29
|
await cmdIdentityShow({ configDir: argv["config-dir"] });
|
package/dist/daemon/ipc.d.ts
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* so the user's CLI can connect when the daemon ran via sudo (otherwise
|
|
21
21
|
* the socket is root-owned). That's acceptable for a single-user box;
|
|
22
22
|
* multi-user hardening can come later via peer-credential filtering.
|
|
23
|
+
* On Windows the equivalent is a DACL that admits interactive users — see
|
|
24
|
+
* grantInteractiveUsers() for why it is not simply "Everyone".
|
|
23
25
|
*/
|
|
24
26
|
export interface IpcHandlers {
|
|
25
27
|
/** Send a friend-request via the daemon's Carrier peer. Returns
|
|
@@ -175,11 +177,54 @@ export declare class IpcServer {
|
|
|
175
177
|
private logger;
|
|
176
178
|
constructor(socketPath: string, handlers: IpcHandlers);
|
|
177
179
|
start(): Promise<void>;
|
|
180
|
+
/**
|
|
181
|
+
* Windows: let a non-elevated app in the user's session open the pipe.
|
|
182
|
+
*
|
|
183
|
+
* The service runs the daemon as SYSTEM, so the pipe gets the default
|
|
184
|
+
* named-pipe DACL: SYSTEM and Administrators full control, Everyone read.
|
|
185
|
+
* Clients open it read/write, and an app the user launches runs with the
|
|
186
|
+
* filtered token (Administrators deny-only), so it gets EPERM. Beagle Desktop
|
|
187
|
+
* then waits on the daemon forever while the same command over ssh — which
|
|
188
|
+
* hands out a full token — attaches at once. chmod has no effect on a pipe.
|
|
189
|
+
*
|
|
190
|
+
* listen({ readableAll, writableAll }) would fix the EPERM, but libuv does it
|
|
191
|
+
* with an Everyone ACE, and Everyone matches network logons: any account that
|
|
192
|
+
* can authenticate to the box over SMB could then drive a SYSTEM daemon. Unix
|
|
193
|
+
* 0666 is local-only; that would not be.
|
|
194
|
+
*
|
|
195
|
+
* Node cannot set a pipe's security descriptor, so replace the DACL once
|
|
196
|
+
* through .NET: SYSTEM and Administrators keep full control, and the
|
|
197
|
+
* Interactive group (S-1-5-4) gets read/write. Interactive is in every console
|
|
198
|
+
* and RDP logon token and in no network logon token. All instances of a pipe
|
|
199
|
+
* name share one security descriptor, so this also covers every instance the
|
|
200
|
+
* server creates later.
|
|
201
|
+
*
|
|
202
|
+
* Fails closed: on any error the pipe keeps the default DACL (the previous
|
|
203
|
+
* behaviour), never a wider one.
|
|
204
|
+
*/
|
|
205
|
+
private grantInteractiveUsers;
|
|
178
206
|
stop(): Promise<void>;
|
|
179
207
|
private isWindowsNamedPipe;
|
|
180
208
|
private handleConnection;
|
|
181
209
|
private dispatch;
|
|
182
210
|
}
|
|
211
|
+
/** The namespace every Windows named-pipe path starts with. */
|
|
212
|
+
export declare const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\";
|
|
213
|
+
/** Named-pipe DACL: SYSTEM and Administrators full control, the Interactive
|
|
214
|
+
* group (S-1-5-4) read/write. Protected, so nothing is inherited — in
|
|
215
|
+
* particular no Everyone. See IpcServer.grantInteractiveUsers(). */
|
|
216
|
+
export declare const WINDOWS_PIPE_SDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
|
|
217
|
+
/**
|
|
218
|
+
* PowerShell that replaces a named pipe's DACL with WINDOWS_PIPE_SDDL and
|
|
219
|
+
* prints the resulting DACL. It must run as the pipe's creator (SYSTEM, which
|
|
220
|
+
* the default DACL grants full control) and opens the pipe as a client with
|
|
221
|
+
* ChangePermissions. Two .NET details, both learned on a real box:
|
|
222
|
+
* ReadData/WriteData are required because NamedPipeClientStream derives the
|
|
223
|
+
* pipe direction from the rights, and only the Access section may be persisted,
|
|
224
|
+
* because the default (All) also writes the SACL, which needs
|
|
225
|
+
* SeSecurityPrivilege and fails as "unauthorized".
|
|
226
|
+
*/
|
|
227
|
+
export declare function windowsPipeDaclScript(pipeName: string): string;
|
|
183
228
|
/** Derive the socket path that pairs with a given carrier data dir.
|
|
184
229
|
* Kept as a one-liner helper so daemon and client agree. */
|
|
185
230
|
export declare function ipcSocketPath(dataDir: string, platform?: NodeJS.Platform): string;
|
package/dist/daemon/ipc.js
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* so the user's CLI can connect when the daemon ran via sudo (otherwise
|
|
21
21
|
* the socket is root-owned). That's acceptable for a single-user box;
|
|
22
22
|
* multi-user hardening can come later via peer-credential filtering.
|
|
23
|
+
* On Windows the equivalent is a DACL that admits interactive users — see
|
|
24
|
+
* grantInteractiveUsers() for why it is not simply "Everyone".
|
|
23
25
|
*/
|
|
26
|
+
import { execFile } from "child_process";
|
|
24
27
|
import { createServer } from "net";
|
|
25
28
|
import { chmodSync, existsSync, readFileSync, unlinkSync } from "fs";
|
|
26
29
|
import { createHash } from "crypto";
|
|
@@ -100,7 +103,10 @@ export class IpcServer {
|
|
|
100
103
|
// 0666 so non-root CLI can reach a sudo-launched daemon. SO_PEERCRED
|
|
101
104
|
// / SO_PEEREID would be a tighter filter, but for v0.1 the trust
|
|
102
105
|
// boundary is "anyone with shell access to this box".
|
|
103
|
-
if (
|
|
106
|
+
if (this.isWindowsNamedPipe()) {
|
|
107
|
+
await this.grantInteractiveUsers();
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
104
110
|
try {
|
|
105
111
|
chmodSync(this.socketPath, 0o666);
|
|
106
112
|
}
|
|
@@ -110,6 +116,51 @@ export class IpcServer {
|
|
|
110
116
|
}
|
|
111
117
|
this.logger.info(`Listening on ${this.socketPath}`);
|
|
112
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Windows: let a non-elevated app in the user's session open the pipe.
|
|
121
|
+
*
|
|
122
|
+
* The service runs the daemon as SYSTEM, so the pipe gets the default
|
|
123
|
+
* named-pipe DACL: SYSTEM and Administrators full control, Everyone read.
|
|
124
|
+
* Clients open it read/write, and an app the user launches runs with the
|
|
125
|
+
* filtered token (Administrators deny-only), so it gets EPERM. Beagle Desktop
|
|
126
|
+
* then waits on the daemon forever while the same command over ssh — which
|
|
127
|
+
* hands out a full token — attaches at once. chmod has no effect on a pipe.
|
|
128
|
+
*
|
|
129
|
+
* listen({ readableAll, writableAll }) would fix the EPERM, but libuv does it
|
|
130
|
+
* with an Everyone ACE, and Everyone matches network logons: any account that
|
|
131
|
+
* can authenticate to the box over SMB could then drive a SYSTEM daemon. Unix
|
|
132
|
+
* 0666 is local-only; that would not be.
|
|
133
|
+
*
|
|
134
|
+
* Node cannot set a pipe's security descriptor, so replace the DACL once
|
|
135
|
+
* through .NET: SYSTEM and Administrators keep full control, and the
|
|
136
|
+
* Interactive group (S-1-5-4) gets read/write. Interactive is in every console
|
|
137
|
+
* and RDP logon token and in no network logon token. All instances of a pipe
|
|
138
|
+
* name share one security descriptor, so this also covers every instance the
|
|
139
|
+
* server creates later.
|
|
140
|
+
*
|
|
141
|
+
* Fails closed: on any error the pipe keeps the default DACL (the previous
|
|
142
|
+
* behaviour), never a wider one.
|
|
143
|
+
*/
|
|
144
|
+
async grantInteractiveUsers() {
|
|
145
|
+
let script;
|
|
146
|
+
try {
|
|
147
|
+
script = windowsPipeDaclScript(this.socketPath.slice(WINDOWS_PIPE_PREFIX.length));
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
this.logger.warn(err instanceof Error ? err.message : String(err));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
154
|
+
const result = await new Promise((resolve) => {
|
|
155
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], { timeout: 20_000, windowsHide: true }, (err, stdout, stderr) => resolve({ err, stdout: String(stdout).trim(), stderr: String(stderr).trim() }));
|
|
156
|
+
});
|
|
157
|
+
if (result.err) {
|
|
158
|
+
this.logger.warn(`Could not open ${this.socketPath} to interactive users; apps without admin rights ` +
|
|
159
|
+
`(Beagle Desktop, a non-elevated CLI) will get EPERM: ${result.stderr || result.err.message}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.logger.info(`Pipe DACL set for interactive users: ${result.stdout}`);
|
|
163
|
+
}
|
|
113
164
|
async stop() {
|
|
114
165
|
if (!this.server)
|
|
115
166
|
return;
|
|
@@ -144,7 +195,7 @@ export class IpcServer {
|
|
|
144
195
|
}
|
|
145
196
|
}
|
|
146
197
|
isWindowsNamedPipe() {
|
|
147
|
-
return this.socketPath.startsWith(
|
|
198
|
+
return this.socketPath.startsWith(WINDOWS_PIPE_PREFIX);
|
|
148
199
|
}
|
|
149
200
|
handleConnection(sock) {
|
|
150
201
|
let buf = "";
|
|
@@ -370,6 +421,45 @@ export class IpcServer {
|
|
|
370
421
|
}
|
|
371
422
|
}
|
|
372
423
|
}
|
|
424
|
+
/** The namespace every Windows named-pipe path starts with. */
|
|
425
|
+
export const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\";
|
|
426
|
+
/** Named-pipe DACL: SYSTEM and Administrators full control, the Interactive
|
|
427
|
+
* group (S-1-5-4) read/write. Protected, so nothing is inherited — in
|
|
428
|
+
* particular no Everyone. See IpcServer.grantInteractiveUsers(). */
|
|
429
|
+
export const WINDOWS_PIPE_SDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
|
|
430
|
+
/**
|
|
431
|
+
* PowerShell that replaces a named pipe's DACL with WINDOWS_PIPE_SDDL and
|
|
432
|
+
* prints the resulting DACL. It must run as the pipe's creator (SYSTEM, which
|
|
433
|
+
* the default DACL grants full control) and opens the pipe as a client with
|
|
434
|
+
* ChangePermissions. Two .NET details, both learned on a real box:
|
|
435
|
+
* ReadData/WriteData are required because NamedPipeClientStream derives the
|
|
436
|
+
* pipe direction from the rights, and only the Access section may be persisted,
|
|
437
|
+
* because the default (All) also writes the SACL, which needs
|
|
438
|
+
* SeSecurityPrivilege and fails as "unauthorized".
|
|
439
|
+
*/
|
|
440
|
+
export function windowsPipeDaclScript(pipeName) {
|
|
441
|
+
// The name is interpolated into the script; ipcSocketPath only ever yields
|
|
442
|
+
// agentnet-<hex>, but refuse anything that could break out of the quotes.
|
|
443
|
+
if (!/^[A-Za-z0-9._-]+$/.test(pipeName)) {
|
|
444
|
+
throw new Error(`Refusing to set a DACL on unexpected pipe name ${JSON.stringify(pipeName)}`);
|
|
445
|
+
}
|
|
446
|
+
return [
|
|
447
|
+
"$ErrorActionPreference = 'Stop'",
|
|
448
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
449
|
+
"try {",
|
|
450
|
+
"$rights = [System.IO.Pipes.PipeAccessRights]'ReadData, WriteData, ReadPermissions, ChangePermissions'",
|
|
451
|
+
`$c = New-Object System.IO.Pipes.NamedPipeClientStream('.', '${pipeName}', $rights, ` +
|
|
452
|
+
"[System.IO.Pipes.PipeOptions]::None, [System.Security.Principal.TokenImpersonationLevel]::None, " +
|
|
453
|
+
"[System.IO.HandleInheritability]::None)",
|
|
454
|
+
"$c.Connect(5000)",
|
|
455
|
+
"$s = New-Object System.IO.Pipes.PipeSecurity",
|
|
456
|
+
`$s.SetSecurityDescriptorSddlForm('${WINDOWS_PIPE_SDDL}', [System.Security.AccessControl.AccessControlSections]::Access)`,
|
|
457
|
+
"$c.SetAccessControl($s)",
|
|
458
|
+
"$c.GetAccessControl().GetSecurityDescriptorSddlForm('Access')",
|
|
459
|
+
"$c.Dispose()",
|
|
460
|
+
"} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }",
|
|
461
|
+
].join("; ");
|
|
462
|
+
}
|
|
373
463
|
/** Derive the socket path that pairs with a given carrier data dir.
|
|
374
464
|
* Kept as a one-liner helper so daemon and client agree. */
|
|
375
465
|
export function ipcSocketPath(dataDir, platform = process.platform) {
|
|
@@ -135,6 +135,12 @@ export class MessageStore {
|
|
|
135
135
|
arr = [];
|
|
136
136
|
this.byPeer.set(peer, arr);
|
|
137
137
|
}
|
|
138
|
+
// ts is the paging cursor (`before` is strict), so it must be unique per
|
|
139
|
+
// thread: a burst in one millisecond would otherwise be split by a page
|
|
140
|
+
// boundary and the rest of it skipped. Order is kept; the id is not touched.
|
|
141
|
+
const newest = arr.length ? arr[arr.length - 1].ts : -Infinity;
|
|
142
|
+
if (msg.ts <= newest)
|
|
143
|
+
msg.ts = newest + 1;
|
|
138
144
|
arr.push(msg);
|
|
139
145
|
if (arr.length > MAX_PER_PEER)
|
|
140
146
|
arr.splice(0, arr.length - MAX_PER_PEER);
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.305";
|
|
2
2
|
const ICON_PATHS = {
|
|
3
3
|
// ---- tab bar (the four must feel like one set) ----
|
|
4
4
|
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.305",
|
|
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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
86
|
"@decentnetwork/dora": "^0.1.14",
|
|
87
|
-
"@decentnetwork/peer": "^0.1.
|
|
87
|
+
"@decentnetwork/peer": "^0.1.168",
|
|
88
88
|
"@decentnetwork/peer-webrtc": "^0.2.10",
|
|
89
89
|
"ink": "^5.2.1",
|
|
90
90
|
"js-yaml": "^4.1.0",
|