@decentnetwork/lan 0.1.303 → 0.1.304

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.
@@ -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;
@@ -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
- await peer.start();
710
- console.log(`My address: ${peer.address()}`);
711
- console.log(`My pubkey: ${peer.pubkey()}`);
712
- console.log(`Joining Carrier network...`);
713
- const joinResult = await peer.joinNetwork();
714
- console.log(`Joined via ${joinResult.respondingNode.host}:${joinResult.respondingNode.port}`);
715
- console.log(`Announcing self (15s)...`);
716
- await peer.announceSelf(15000).catch((err) => {
717
- console.warn(`Self-announce failed: ${err.message}`);
718
- });
719
- // Fire all requests on the one announced session.
720
- for (const t of args.targets) {
721
- try {
722
- console.log(`Sending friend request to ${t.label} (${t.address.slice(0, 16)}...)...`);
723
- await peer.sendFriendRequest(t.address, args.hello || "Decent AgentNet friend request");
724
- results.push({ label: t.label, address: t.address, ok: true });
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
- catch (err) {
727
- const msg = err instanceof Error ? err.message : String(err);
728
- console.warn(` Failed to send to ${t.label}: ${msg}`);
729
- results.push({ label: t.label, address: t.address, ok: false, error: msg });
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
- // Single shared wait for relay delivery of all requests above.
733
- const waitMs = args.waitMs ?? 8000;
734
- console.log(`Waiting ${waitMs}ms for relay delivery...`);
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
- & ${psq(args.nodeBin)} ${psq(args.cliEntry)} up --real-tun --config-dir ${psq(args.configDir)} *>> ${psq(args.logPath)}
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
- "$(Get-Date -Format o) agentnet exited with code $code; restarting in 5 seconds" | Out-File -FilePath ${psq(args.logPath)} -Append -Encoding utf8
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 = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
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
- await fs.writeFile(scriptPath, script, "utf-8");
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
- // Resolve the `agentnet` binary. argv[1] is dist/cli/index.js when
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} ${agentnetBin} up --real-tun --config-dir ${dir}
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
- let agentnetBin;
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>${agentnetBin}</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" }), async (argv) => {
21
- await cmdInit({ name: argv.name, configDir: argv["config-dir"] });
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"] });
@@ -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;
@@ -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 (!this.isWindowsNamedPipe()) {
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("\\\\.\\pipe\\");
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) {
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.303";
1
+ window.__DK_UI_VERSION="0.1.304";
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.303",
3
+ "version": "0.1.304",
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.166",
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",