@decentnetwork/lan 0.1.197 → 0.1.199
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/README.md +3 -3
- package/bin/WINTUN-LICENSE.txt +84 -0
- package/bin/WINTUN-SOURCE.json +10 -0
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/bin/windows-amd64/tun-helper-windows-amd64.exe +0 -0
- package/bin/windows-amd64/wintun.dll +0 -0
- package/bin/windows-arm64/tun-helper-windows-arm64.exe +0 -0
- package/bin/windows-arm64/wintun.dll +0 -0
- package/dist/cli/commands.d.ts +14 -9
- package/dist/cli/commands.js +98 -10
- package/dist/cli/index.js +3 -3
- package/dist/console/console.js +11 -1
- package/dist/daemon/ipc.d.ts +9 -8
- package/dist/daemon/ipc.js +33 -19
- package/dist/daemon/server.js +58 -14
- package/dist/proxy/hls-prefetch.js +78 -15
- package/dist/proxy/multi-exit-router.d.ts +1 -0
- package/dist/proxy/multi-exit-router.js +49 -2
- package/dist/tun/route-manager.d.ts +12 -2
- package/dist/tun/route-manager.js +80 -14
- package/dist/tun/tun-device.d.ts +4 -0
- package/dist/tun/tun-device.js +37 -20
- package/docs/CONFIGURATION.md +2 -2
- package/docs/INSTALL-WINDOWS.md +137 -0
- package/docs/INSTALL.md +14 -22
- package/package.json +8 -3
|
@@ -179,6 +179,8 @@ export class HlsPrefetcher {
|
|
|
179
179
|
#cache = new Map(); // Map preserves insertion order → LRU-ish
|
|
180
180
|
#cacheBytes = 0;
|
|
181
181
|
#inflight = new Map();
|
|
182
|
+
#background = new Map();
|
|
183
|
+
#playlistGeneration = 0;
|
|
182
184
|
#liveBufferPrimed = false;
|
|
183
185
|
#fetcher;
|
|
184
186
|
#maxBytes;
|
|
@@ -266,7 +268,7 @@ export class HlsPrefetcher {
|
|
|
266
268
|
* losing request is aborted immediately so the rescue does not keep splitting
|
|
267
269
|
* bandwidth after a winner is available. Redemption probes are separate,
|
|
268
270
|
* forced-exit requests and are never aborted by this path. */
|
|
269
|
-
#whole(u, extra) {
|
|
271
|
+
#whole(u, extra, outerSignal) {
|
|
270
272
|
const one = (signal, label) => this.#fetcher(u, { ...extra, host: u.host }, label, signal);
|
|
271
273
|
return new Promise((resolve, reject) => {
|
|
272
274
|
let settled = false;
|
|
@@ -275,12 +277,25 @@ export class HlsPrefetcher {
|
|
|
275
277
|
let timer = null;
|
|
276
278
|
const first = new AbortController();
|
|
277
279
|
let hedge = null;
|
|
280
|
+
const cleanup = () => outerSignal?.removeEventListener("abort", onOuterAbort);
|
|
281
|
+
const onOuterAbort = () => {
|
|
282
|
+
if (settled)
|
|
283
|
+
return;
|
|
284
|
+
settled = true;
|
|
285
|
+
if (timer)
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
first.abort();
|
|
288
|
+
hedge?.abort();
|
|
289
|
+
cleanup();
|
|
290
|
+
reject(new Error("aborted"));
|
|
291
|
+
};
|
|
278
292
|
const win = (winner) => (r) => {
|
|
279
293
|
if (settled)
|
|
280
294
|
return;
|
|
281
295
|
settled = true;
|
|
282
296
|
if (timer)
|
|
283
297
|
clearTimeout(timer);
|
|
298
|
+
cleanup();
|
|
284
299
|
(winner === first ? hedge : first)?.abort();
|
|
285
300
|
// Keep the winner alive through resolution; aborting a fully consumed
|
|
286
301
|
// response would only discard its reusable keep-alive socket.
|
|
@@ -298,6 +313,7 @@ export class HlsPrefetcher {
|
|
|
298
313
|
}
|
|
299
314
|
else if (pending === 0) {
|
|
300
315
|
settled = true;
|
|
316
|
+
cleanup();
|
|
301
317
|
reject(e);
|
|
302
318
|
}
|
|
303
319
|
};
|
|
@@ -309,6 +325,11 @@ export class HlsPrefetcher {
|
|
|
309
325
|
pending++;
|
|
310
326
|
one(hedge.signal, "prefetch-hedge").then(win(hedge), lose);
|
|
311
327
|
};
|
|
328
|
+
if (outerSignal?.aborted) {
|
|
329
|
+
onOuterAbort();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
312
333
|
one(first.signal, "prefetch").then(win(first), lose);
|
|
313
334
|
timer = setTimeout(() => {
|
|
314
335
|
timer = null;
|
|
@@ -322,7 +343,7 @@ export class HlsPrefetcher {
|
|
|
322
343
|
* STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
|
|
323
344
|
* given, rejects a 206 whose body isn't exactly that long (so assembly is
|
|
324
345
|
* byte-correct); the probe passes it undefined to accept whatever 206 arrives. */
|
|
325
|
-
#fetchRange(u, start, end, extra, exactLen, exitHint) {
|
|
346
|
+
#fetchRange(u, start, end, extra, exactLen, exitHint, outerSignal) {
|
|
326
347
|
return new Promise((resolve) => {
|
|
327
348
|
let settled = false;
|
|
328
349
|
let launched = 0;
|
|
@@ -338,11 +359,13 @@ export class HlsPrefetcher {
|
|
|
338
359
|
return;
|
|
339
360
|
settled = true;
|
|
340
361
|
clearHedge();
|
|
362
|
+
outerSignal?.removeEventListener("abort", onOuterAbort);
|
|
341
363
|
for (const ac of controllers)
|
|
342
364
|
if (ac !== winner)
|
|
343
365
|
ac.abort();
|
|
344
366
|
resolve(r);
|
|
345
367
|
};
|
|
368
|
+
const onOuterAbort = () => finish(null);
|
|
346
369
|
const scheduleHedge = () => {
|
|
347
370
|
if (settled || launched >= STRIPE_TRIES || hedgeTimer)
|
|
348
371
|
return;
|
|
@@ -387,6 +410,11 @@ export class HlsPrefetcher {
|
|
|
387
410
|
if (finished >= launched)
|
|
388
411
|
finish(null);
|
|
389
412
|
};
|
|
413
|
+
if (outerSignal?.aborted) {
|
|
414
|
+
finish(null);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
390
418
|
launch();
|
|
391
419
|
});
|
|
392
420
|
}
|
|
@@ -394,25 +422,29 @@ export class HlsPrefetcher {
|
|
|
394
422
|
* each hedged. Assembles them into the full body with a corrected
|
|
395
423
|
* content-length. Falls back to one whole fetch if the origin ignores Range or
|
|
396
424
|
* any stripe truly can't be fetched (correctness over speed). */
|
|
397
|
-
async #stripeFetch(u, extra) {
|
|
425
|
+
async #stripeFetch(u, extra, signal) {
|
|
426
|
+
if (signal?.aborted)
|
|
427
|
+
throw new Error("aborted");
|
|
398
428
|
const stripeCount = this.#stripeCount();
|
|
399
429
|
// A count of one explicitly disables striping (emergency rollback or an
|
|
400
430
|
// origin/path known not to benefit from parallel range connections).
|
|
401
431
|
if (stripeCount <= 1)
|
|
402
|
-
return this.#whole(u, extra);
|
|
432
|
+
return this.#whole(u, extra, signal);
|
|
403
433
|
// Tiny probe (2 bytes): learn the total size + confirm the origin honors
|
|
404
434
|
// Range, cheaply — a big sequential probe would add a whole round-trip of
|
|
405
435
|
// latency before any stripe starts and starve the live buffer.
|
|
406
|
-
const probe = await this.#fetchRange(u, 0, 1, extra);
|
|
436
|
+
const probe = await this.#fetchRange(u, 0, 1, extra, undefined, undefined, signal);
|
|
437
|
+
if (signal?.aborted)
|
|
438
|
+
throw new Error("aborted");
|
|
407
439
|
if (!probe)
|
|
408
|
-
return this.#whole(u, extra);
|
|
440
|
+
return this.#whole(u, extra, signal);
|
|
409
441
|
if (probe.status === 200)
|
|
410
442
|
return probe; // range ignored → whole body already here
|
|
411
443
|
if (probe.status !== 206)
|
|
412
|
-
return this.#whole(u, extra);
|
|
444
|
+
return this.#whole(u, extra, signal);
|
|
413
445
|
const total = contentRangeTotal(probe.headers["content-range"]);
|
|
414
446
|
if (!total || total <= 0)
|
|
415
|
-
return this.#whole(u, extra);
|
|
447
|
+
return this.#whole(u, extra, signal);
|
|
416
448
|
const headers = { ...probe.headers };
|
|
417
449
|
delete headers["content-range"];
|
|
418
450
|
// Split the WHOLE body into N equal stripes, fetched IN PARALLEL across the
|
|
@@ -427,13 +459,15 @@ export class HlsPrefetcher {
|
|
|
427
459
|
if (start > end)
|
|
428
460
|
return Promise.resolve(Buffer.alloc(0));
|
|
429
461
|
const want = end - start + 1;
|
|
430
|
-
return this.#fetchRange(u, start, end, extra, want, i).then((r) => (r && r.body.length === want ? r.body : null));
|
|
462
|
+
return this.#fetchRange(u, start, end, extra, want, i, signal).then((r) => (r && r.body.length === want ? r.body : null));
|
|
431
463
|
}));
|
|
464
|
+
if (signal?.aborted)
|
|
465
|
+
throw new Error("aborted");
|
|
432
466
|
if (parts.some((p) => p === null))
|
|
433
|
-
return this.#whole(u, extra); // a stripe couldn't be fetched → whole for integrity
|
|
467
|
+
return this.#whole(u, extra, signal); // a stripe couldn't be fetched → whole for integrity
|
|
434
468
|
const body = Buffer.concat(parts);
|
|
435
469
|
if (body.length !== total)
|
|
436
|
-
return this.#whole(u, extra);
|
|
470
|
+
return this.#whole(u, extra, signal);
|
|
437
471
|
headers["content-length"] = String(body.length);
|
|
438
472
|
return { status: 200, headers, body };
|
|
439
473
|
}
|
|
@@ -523,12 +557,35 @@ export class HlsPrefetcher {
|
|
|
523
557
|
const segs = parseM3u8Segments(body, playlistUrl);
|
|
524
558
|
if (segs.length === 0)
|
|
525
559
|
return;
|
|
560
|
+
const generation = ++this.#playlistGeneration;
|
|
526
561
|
// For a live playlist the tail is "upcoming"; for VOD the player walks the
|
|
527
562
|
// list from wherever it is, and refetches of the playlist don't happen —
|
|
528
563
|
// prefetching the head of the list still fills the startup buffer. Either
|
|
529
564
|
// way: take the last `ahead` entries we don't already hold.
|
|
565
|
+
const tail = segs.slice(-this.#ahead);
|
|
566
|
+
const wanted = new Set(tail);
|
|
567
|
+
const cancelled = [];
|
|
568
|
+
for (const [url, controller] of this.#background) {
|
|
569
|
+
if (wanted.has(url))
|
|
570
|
+
continue;
|
|
571
|
+
controller.abort();
|
|
572
|
+
this.#background.delete(url);
|
|
573
|
+
const p = this.#inflight.get(url);
|
|
574
|
+
if (p)
|
|
575
|
+
cancelled.push(p);
|
|
576
|
+
}
|
|
577
|
+
if (cancelled.length > 0) {
|
|
578
|
+
void Promise.allSettled(cancelled).then(() => {
|
|
579
|
+
if (this.#playlistGeneration === generation)
|
|
580
|
+
this.#schedulePrefetch(tail);
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.#schedulePrefetch(tail);
|
|
585
|
+
}
|
|
586
|
+
#schedulePrefetch(segs) {
|
|
530
587
|
const conc = Math.max(1, Math.floor(this.#concurrencyFn ? this.#concurrencyFn() : this.#concurrency));
|
|
531
|
-
const want = segs.
|
|
588
|
+
const want = segs.filter((u) => !this.#peek(u) && !this.#inflight.has(u));
|
|
532
589
|
for (const u of want) {
|
|
533
590
|
if (this.#inflight.size >= conc)
|
|
534
591
|
break;
|
|
@@ -536,10 +593,13 @@ export class HlsPrefetcher {
|
|
|
536
593
|
}
|
|
537
594
|
}
|
|
538
595
|
#prefetch(url) {
|
|
539
|
-
const
|
|
596
|
+
const controller = new AbortController();
|
|
597
|
+
this.#background.set(url, controller);
|
|
598
|
+
let p;
|
|
599
|
+
p = (async () => {
|
|
540
600
|
try {
|
|
541
601
|
const u = new URL(url);
|
|
542
|
-
const res = await this.#stripeFetch(u, {});
|
|
602
|
+
const res = await this.#stripeFetch(u, {}, controller.signal);
|
|
543
603
|
if (res.status !== 200 || res.body.length === 0 || res.body.length > this.#maxEntryBytes)
|
|
544
604
|
return null;
|
|
545
605
|
const ct = res.headers["content-type"];
|
|
@@ -555,7 +615,10 @@ export class HlsPrefetcher {
|
|
|
555
615
|
return null;
|
|
556
616
|
}
|
|
557
617
|
finally {
|
|
558
|
-
this.#inflight.
|
|
618
|
+
if (this.#inflight.get(url) === p)
|
|
619
|
+
this.#inflight.delete(url);
|
|
620
|
+
if (this.#background.get(url) === controller)
|
|
621
|
+
this.#background.delete(url);
|
|
559
622
|
}
|
|
560
623
|
})();
|
|
561
624
|
this.#inflight.set(url, p);
|
|
@@ -50,6 +50,7 @@ export interface MultiExitRouterOptions {
|
|
|
50
50
|
}
|
|
51
51
|
export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
|
|
52
52
|
export declare function isUsefulRecoverySample(peakBps: number, averageBps: number, minBps?: number): boolean;
|
|
53
|
+
export declare function redemptionProbeQuietDelay(lastForegroundMs: number, nowMs?: number, quietMs?: number): number;
|
|
53
54
|
interface HlsExitScore {
|
|
54
55
|
speedEwma: number | null;
|
|
55
56
|
flooredAtMs: number | null;
|
|
@@ -239,6 +239,14 @@ const REDEMPTION_MS = 90_000;
|
|
|
239
239
|
// exit is tested with a duplicate background segment while the player's normal
|
|
240
240
|
// fetch remains on proven exits, so recovery discovery cannot stall playback.
|
|
241
241
|
const REDEMPTION_PROBE_GAP_MS = 15_000;
|
|
242
|
+
// A recovery test is deliberately background work: never let it overlap an
|
|
243
|
+
// active live stream. Each foreground segment pushes the timer back; once the
|
|
244
|
+
// player has been quiet for this long, the most recent segment is safe to reuse
|
|
245
|
+
// as a realistic recovery sample.
|
|
246
|
+
const REDEMPTION_PROBE_QUIET_MS = 10_000;
|
|
247
|
+
export function redemptionProbeQuietDelay(lastForegroundMs, nowMs = Date.now(), quietMs = REDEMPTION_PROBE_QUIET_MS) {
|
|
248
|
+
return Math.max(0, quietMs - Math.max(0, nowMs - lastForegroundMs));
|
|
249
|
+
}
|
|
242
250
|
function floorExit(exit) {
|
|
243
251
|
exit.speedEwma = exit.speedEwma === null ? FLOOR_BPS : Math.min(exit.speedEwma, FLOOR_BPS);
|
|
244
252
|
exit.flooredAtMs = Date.now();
|
|
@@ -1137,6 +1145,38 @@ export function startMultiExitRouter(opts) {
|
|
|
1137
1145
|
// Fetch one URL through the region's exit pool, buffered, with failover.
|
|
1138
1146
|
let redemptionProbeInFlight = false;
|
|
1139
1147
|
let lastRedemptionProbeMs = 0;
|
|
1148
|
+
let lastForegroundHlsMs = 0;
|
|
1149
|
+
let redemptionProbeTimer = null;
|
|
1150
|
+
let pendingRedemptionProbe = null;
|
|
1151
|
+
function armRedemptionProbe(delayMs) {
|
|
1152
|
+
if (redemptionProbeTimer)
|
|
1153
|
+
clearTimeout(redemptionProbeTimer);
|
|
1154
|
+
redemptionProbeTimer = setTimeout(() => {
|
|
1155
|
+
redemptionProbeTimer = null;
|
|
1156
|
+
const quietDelay = redemptionProbeQuietDelay(lastForegroundHlsMs);
|
|
1157
|
+
if (quietDelay > 0) {
|
|
1158
|
+
armRedemptionProbe(quietDelay);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const pending = pendingRedemptionProbe;
|
|
1162
|
+
if (!pending)
|
|
1163
|
+
return;
|
|
1164
|
+
if (redemptionProbeInFlight || Date.now() - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS) {
|
|
1165
|
+
armRedemptionProbe(REDEMPTION_PROBE_GAP_MS);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
maybeStartRedemptionProbe(pending.u, pending.extraHeaders, pending.region);
|
|
1169
|
+
// Keep checking while the player remains idle: an exit may not become
|
|
1170
|
+
// redemption-eligible until its 90-second floor expires.
|
|
1171
|
+
armRedemptionProbe(REDEMPTION_PROBE_GAP_MS);
|
|
1172
|
+
}, Math.max(1, delayMs));
|
|
1173
|
+
redemptionProbeTimer.unref?.();
|
|
1174
|
+
}
|
|
1175
|
+
function deferRedemptionProbeUntilIdle(u, extraHeaders, region) {
|
|
1176
|
+
lastForegroundHlsMs = Date.now();
|
|
1177
|
+
pendingRedemptionProbe = { u: new URL(u.toString()), extraHeaders: { ...extraHeaders }, region };
|
|
1178
|
+
armRedemptionProbe(REDEMPTION_PROBE_QUIET_MS);
|
|
1179
|
+
}
|
|
1140
1180
|
function maybeStartRedemptionProbe(u, extraHeaders, region) {
|
|
1141
1181
|
const now = Date.now();
|
|
1142
1182
|
if (redemptionProbeInFlight || now - lastRedemptionProbeMs < REDEMPTION_PROBE_GAP_MS)
|
|
@@ -1205,7 +1245,7 @@ export function startMultiExitRouter(opts) {
|
|
|
1205
1245
|
return;
|
|
1206
1246
|
}
|
|
1207
1247
|
if (!forcedExit && label === "prefetch")
|
|
1208
|
-
|
|
1248
|
+
deferRedemptionProbeUntilIdle(u, extraHeaders, region);
|
|
1209
1249
|
const base0 = forcedExit ? [forcedExit] : pickOrder(region);
|
|
1210
1250
|
if (base0.length === 0) {
|
|
1211
1251
|
reject(new Error("no healthy exit"));
|
|
@@ -1774,6 +1814,13 @@ export function startMultiExitRouter(opts) {
|
|
|
1774
1814
|
server.on("close", () => clearInterval(reporter));
|
|
1775
1815
|
}
|
|
1776
1816
|
return {
|
|
1777
|
-
stop: () => {
|
|
1817
|
+
stop: () => {
|
|
1818
|
+
stopped = true;
|
|
1819
|
+
if (redemptionProbeTimer)
|
|
1820
|
+
clearTimeout(redemptionProbeTimer);
|
|
1821
|
+
redemptionProbeTimer = null;
|
|
1822
|
+
pendingRedemptionProbe = null;
|
|
1823
|
+
server.close();
|
|
1824
|
+
},
|
|
1778
1825
|
};
|
|
1779
1826
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TUN device + route management.
|
|
3
|
-
* Linux uses `ip` (iproute2)
|
|
3
|
+
* Linux uses `ip` (iproute2), macOS uses `ifconfig` + `route`, and
|
|
4
|
+
* Windows uses the built-in NetTCPIP PowerShell cmdlets.
|
|
4
5
|
*/
|
|
5
6
|
import type { TunDeviceConfig } from "./types.js";
|
|
7
|
+
export type RoutePlatform = "linux" | "darwin" | "win32";
|
|
8
|
+
export interface RouteManagerOptions {
|
|
9
|
+
platform?: RoutePlatform;
|
|
10
|
+
commandRunner?: (command: string) => string;
|
|
11
|
+
}
|
|
6
12
|
export declare class RouteManager {
|
|
7
13
|
private logger;
|
|
8
14
|
private createdInterface;
|
|
9
15
|
private platform;
|
|
10
|
-
|
|
16
|
+
private commandRunner;
|
|
17
|
+
constructor(opts?: RouteManagerOptions);
|
|
11
18
|
/**
|
|
12
19
|
* Create and configure TUN interface (Linux only — on macOS the helper
|
|
13
20
|
* binary creates the utun device implicitly).
|
|
@@ -18,6 +25,7 @@ export declare class RouteManager {
|
|
|
18
25
|
* `name` here should be the ACTUAL device name (e.g. utun12 on macOS).
|
|
19
26
|
*/
|
|
20
27
|
configureTun(config: TunDeviceConfig): Promise<void>;
|
|
28
|
+
private configureTunWindows;
|
|
21
29
|
private configureTunLinux;
|
|
22
30
|
private configureTunDarwin;
|
|
23
31
|
/**
|
|
@@ -43,6 +51,8 @@ export declare class RouteManager {
|
|
|
43
51
|
setupMasquerade(subnet: string, outInterface: string, tunInterface: string): Promise<void>;
|
|
44
52
|
cleanupMasquerade(subnet: string, outInterface: string, tunInterface: string): Promise<void>;
|
|
45
53
|
private exec;
|
|
54
|
+
private powerShell;
|
|
55
|
+
private powerShellQuote;
|
|
46
56
|
/**
|
|
47
57
|
* Convert "10.86.0.0/16" -> 16
|
|
48
58
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TUN device + route management.
|
|
3
|
-
* Linux uses `ip` (iproute2)
|
|
3
|
+
* Linux uses `ip` (iproute2), macOS uses `ifconfig` + `route`, and
|
|
4
|
+
* Windows uses the built-in NetTCPIP PowerShell cmdlets.
|
|
4
5
|
*/
|
|
5
6
|
import { execSync } from "child_process";
|
|
6
7
|
import { Logger } from "../utils/logger.js";
|
|
@@ -8,22 +9,23 @@ export class RouteManager {
|
|
|
8
9
|
logger;
|
|
9
10
|
createdInterface = null;
|
|
10
11
|
platform;
|
|
11
|
-
|
|
12
|
+
commandRunner;
|
|
13
|
+
constructor(opts = {}) {
|
|
12
14
|
this.logger = new Logger({ prefix: "RouteManager" });
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
else {
|
|
17
|
-
this.platform = "linux";
|
|
15
|
+
const detected = opts.platform ?? process.platform;
|
|
16
|
+
if (detected !== "linux" && detected !== "darwin" && detected !== "win32") {
|
|
17
|
+
throw new Error(`Unsupported route platform: ${detected}`);
|
|
18
18
|
}
|
|
19
|
+
this.platform = detected;
|
|
20
|
+
this.commandRunner = opts.commandRunner ?? ((command) => execSync(command, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }));
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Create and configure TUN interface (Linux only — on macOS the helper
|
|
22
24
|
* binary creates the utun device implicitly).
|
|
23
25
|
*/
|
|
24
26
|
async createTun(config) {
|
|
25
|
-
if (this.platform === "darwin") {
|
|
26
|
-
// utun is auto-created by the helper; just configure
|
|
27
|
+
if (this.platform === "darwin" || this.platform === "win32") {
|
|
28
|
+
// utun/Wintun is auto-created by the helper; just configure.
|
|
27
29
|
await this.configureTun(config);
|
|
28
30
|
return;
|
|
29
31
|
}
|
|
@@ -48,8 +50,34 @@ export class RouteManager {
|
|
|
48
50
|
if (this.platform === "darwin") {
|
|
49
51
|
return this.configureTunDarwin(config);
|
|
50
52
|
}
|
|
53
|
+
if (this.platform === "win32") {
|
|
54
|
+
return this.configureTunWindows(config);
|
|
55
|
+
}
|
|
51
56
|
return this.configureTunLinux(config);
|
|
52
57
|
}
|
|
58
|
+
async configureTunWindows(config) {
|
|
59
|
+
const { name, ip, subnet } = config;
|
|
60
|
+
const mtu = config.mtu ?? 900;
|
|
61
|
+
const alias = this.powerShellQuote(name);
|
|
62
|
+
const address = this.powerShellQuote(ip);
|
|
63
|
+
const destination = this.powerShellQuote(subnet);
|
|
64
|
+
// Use a /32 local address and add the AgentNet /16 explicitly. This mirrors
|
|
65
|
+
// the macOS point-to-point setup and avoids Windows creating a competing
|
|
66
|
+
// connected /16 route with an arbitrary metric.
|
|
67
|
+
this.powerShell([
|
|
68
|
+
"$ErrorActionPreference='Stop'",
|
|
69
|
+
`Enable-NetAdapter -Name ${alias} -Confirm:$false -ErrorAction SilentlyContinue`,
|
|
70
|
+
`Get-NetIPAddress -InterfaceAlias ${alias} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -like '10.86.*' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue`,
|
|
71
|
+
`Get-NetRoute -InterfaceAlias ${alias} -DestinationPrefix ${destination} -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`,
|
|
72
|
+
`New-NetIPAddress -InterfaceAlias ${alias} -IPAddress ${address} -PrefixLength 32 -AddressFamily IPv4 -PolicyStore ActiveStore | Out-Null`,
|
|
73
|
+
`Set-NetIPInterface -InterfaceAlias ${alias} -AddressFamily IPv4 -NlMtuBytes ${mtu}`,
|
|
74
|
+
`New-NetRoute -DestinationPrefix ${destination} -InterfaceAlias ${alias} -NextHop 0.0.0.0 -RouteMetric 1 -PolicyStore ActiveStore | Out-Null`,
|
|
75
|
+
`Get-NetFirewallRule -Name 'DecentAgentNet-Inbound' -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue`,
|
|
76
|
+
`New-NetFirewallRule -Name 'DecentAgentNet-Inbound' -DisplayName 'Decent AgentNet virtual network' -Direction Inbound -Action Allow -InterfaceAlias ${alias} -Profile Any | Out-Null`,
|
|
77
|
+
].join("; "));
|
|
78
|
+
this.createdInterface = name;
|
|
79
|
+
this.logger.info(`Assigned ${ip}/32 to ${name}, route ${subnet}, mtu=${mtu} (Windows)`);
|
|
80
|
+
}
|
|
53
81
|
async configureTunLinux(config) {
|
|
54
82
|
const { name, ip, subnet } = config;
|
|
55
83
|
const mtu = config.mtu ?? 900;
|
|
@@ -162,6 +190,13 @@ export class RouteManager {
|
|
|
162
190
|
* Remove TUN interface (Linux only — on macOS utun goes away when helper exits).
|
|
163
191
|
*/
|
|
164
192
|
async removeTun(name) {
|
|
193
|
+
if (this.platform === "win32") {
|
|
194
|
+
// Wintun adapters are intentionally persistent and reused by name. The
|
|
195
|
+
// helper ending its session is enough; cleanup() removes IP/routes.
|
|
196
|
+
this.logger.debug(`Windows Wintun adapter ${name} retained for reuse`);
|
|
197
|
+
this.createdInterface = null;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
165
200
|
if (this.platform === "darwin") {
|
|
166
201
|
// utun is auto-destroyed when the helper subprocess exits
|
|
167
202
|
this.logger.debug(`macOS utun ${name} cleaned up by helper exit`);
|
|
@@ -221,6 +256,21 @@ export class RouteManager {
|
|
|
221
256
|
// utun device cleanup is handled when helper exits
|
|
222
257
|
return;
|
|
223
258
|
}
|
|
259
|
+
if (this.platform === "win32") {
|
|
260
|
+
const alias = this.powerShellQuote(name);
|
|
261
|
+
const destination = this.powerShellQuote(subnet);
|
|
262
|
+
const removeIp = ip
|
|
263
|
+
? `Get-NetIPAddress -InterfaceAlias ${alias} -IPAddress ${this.powerShellQuote(ip)} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue`
|
|
264
|
+
: `Get-NetIPAddress -InterfaceAlias ${alias} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -like '10.86.*' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue`;
|
|
265
|
+
this.powerShell([
|
|
266
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
267
|
+
`Get-NetRoute -InterfaceAlias ${alias} -DestinationPrefix ${destination} -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`,
|
|
268
|
+
removeIp,
|
|
269
|
+
`Get-NetFirewallRule -Name 'DecentAgentNet-Inbound' -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue`,
|
|
270
|
+
].join("; "));
|
|
271
|
+
this.createdInterface = null;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
224
274
|
try {
|
|
225
275
|
this.exec(`ip route del ${subnet} dev ${name}`);
|
|
226
276
|
}
|
|
@@ -242,6 +292,10 @@ export class RouteManager {
|
|
|
242
292
|
* Enable IP forwarding (Linux only)
|
|
243
293
|
*/
|
|
244
294
|
async enableIpForwarding() {
|
|
295
|
+
if (this.platform === "win32") {
|
|
296
|
+
this.logger.warn("Kernel IP forwarding/NAT is not enabled on Windows; the AgentNet CONNECT exit proxy does not require it");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
245
299
|
if (this.platform === "darwin") {
|
|
246
300
|
try {
|
|
247
301
|
this.exec("sysctl -w net.inet.ip.forwarding=1");
|
|
@@ -264,7 +318,10 @@ export class RouteManager {
|
|
|
264
318
|
}
|
|
265
319
|
async disableIpForwarding() {
|
|
266
320
|
try {
|
|
267
|
-
if (this.platform === "
|
|
321
|
+
if (this.platform === "win32") {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
else if (this.platform === "darwin") {
|
|
268
325
|
this.exec("sysctl -w net.inet.ip.forwarding=0");
|
|
269
326
|
}
|
|
270
327
|
else {
|
|
@@ -281,8 +338,8 @@ export class RouteManager {
|
|
|
281
338
|
* Not implemented on macOS in MVP.
|
|
282
339
|
*/
|
|
283
340
|
async setupMasquerade(subnet, outInterface, tunInterface) {
|
|
284
|
-
if (this.platform === "darwin") {
|
|
285
|
-
this.logger.warn(
|
|
341
|
+
if (this.platform === "darwin" || this.platform === "win32") {
|
|
342
|
+
this.logger.warn(`Masquerade not implemented on ${this.platform} in MVP`);
|
|
286
343
|
return;
|
|
287
344
|
}
|
|
288
345
|
this.exec(`iptables -t nat -A POSTROUTING -o ${outInterface} -j MASQUERADE`);
|
|
@@ -291,7 +348,7 @@ export class RouteManager {
|
|
|
291
348
|
this.logger.info(`Masquerade setup for ${subnet} (${tunInterface} -> ${outInterface})`);
|
|
292
349
|
}
|
|
293
350
|
async cleanupMasquerade(subnet, outInterface, tunInterface) {
|
|
294
|
-
if (this.platform === "darwin")
|
|
351
|
+
if (this.platform === "darwin" || this.platform === "win32")
|
|
295
352
|
return;
|
|
296
353
|
for (const cmd of [
|
|
297
354
|
`iptables -t nat -D POSTROUTING -o ${outInterface} -j MASQUERADE`,
|
|
@@ -310,13 +367,22 @@ export class RouteManager {
|
|
|
310
367
|
exec(command) {
|
|
311
368
|
this.logger.debug(`Executing: ${command}`);
|
|
312
369
|
try {
|
|
313
|
-
return
|
|
370
|
+
return this.commandRunner(command);
|
|
314
371
|
}
|
|
315
372
|
catch (error) {
|
|
316
373
|
const message = error instanceof Error ? error.message : String(error);
|
|
317
374
|
throw new Error(`Command failed: ${command}\n${message}`);
|
|
318
375
|
}
|
|
319
376
|
}
|
|
377
|
+
powerShell(script) {
|
|
378
|
+
// -EncodedCommand is UTF-16LE and avoids cmd.exe quoting/injection hazards
|
|
379
|
+
// from interface aliases or Windows paths.
|
|
380
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
381
|
+
return this.exec(`powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`);
|
|
382
|
+
}
|
|
383
|
+
powerShellQuote(value) {
|
|
384
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
385
|
+
}
|
|
320
386
|
/**
|
|
321
387
|
* Convert "10.86.0.0/16" -> 16
|
|
322
388
|
*/
|
package/dist/tun/tun-device.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export interface TunDeviceOptions {
|
|
|
14
14
|
mockMode?: boolean;
|
|
15
15
|
helperPath?: string;
|
|
16
16
|
}
|
|
17
|
+
/** Relative helper path inside the package's bin/ directory. Windows keeps
|
|
18
|
+
* each architecture in its own directory because the matching driver DLL must
|
|
19
|
+
* be named exactly `wintun.dll` beside the executable. */
|
|
20
|
+
export declare function helperBinaryRelativePath(platform: NodeJS.Platform, arch: string): string | null;
|
|
17
21
|
export declare class TunDevice extends EventEmitter {
|
|
18
22
|
private config;
|
|
19
23
|
private logger;
|
package/dist/tun/tun-device.js
CHANGED
|
@@ -13,6 +13,24 @@ import { resolve, dirname } from "path";
|
|
|
13
13
|
import { existsSync } from "fs";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { Logger } from "../utils/logger.js";
|
|
16
|
+
/** Relative helper path inside the package's bin/ directory. Windows keeps
|
|
17
|
+
* each architecture in its own directory because the matching driver DLL must
|
|
18
|
+
* be named exactly `wintun.dll` beside the executable. */
|
|
19
|
+
export function helperBinaryRelativePath(platform, arch) {
|
|
20
|
+
if (platform === "linux" && arch === "x64")
|
|
21
|
+
return "tun-helper-linux-amd64";
|
|
22
|
+
if (platform === "linux" && arch === "arm64")
|
|
23
|
+
return "tun-helper-linux-arm64";
|
|
24
|
+
if (platform === "darwin" && arch === "x64")
|
|
25
|
+
return "tun-helper-darwin-amd64";
|
|
26
|
+
if (platform === "darwin" && arch === "arm64")
|
|
27
|
+
return "tun-helper-darwin-arm64";
|
|
28
|
+
if (platform === "win32" && arch === "x64")
|
|
29
|
+
return "windows-amd64/tun-helper-windows-amd64.exe";
|
|
30
|
+
if (platform === "win32" && arch === "arm64")
|
|
31
|
+
return "windows-arm64/tun-helper-windows-arm64.exe";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
16
34
|
export class TunDevice extends EventEmitter {
|
|
17
35
|
config;
|
|
18
36
|
logger;
|
|
@@ -81,7 +99,19 @@ export class TunDevice extends EventEmitter {
|
|
|
81
99
|
clearTimeout(timer);
|
|
82
100
|
res();
|
|
83
101
|
});
|
|
84
|
-
helper
|
|
102
|
+
// Closing stdin is the helper's cross-platform graceful-shutdown
|
|
103
|
+
// signal. In particular, Node maps kill("SIGTERM") to an immediate
|
|
104
|
+
// TerminateProcess on Windows, which can interrupt Wintun inside a DLL
|
|
105
|
+
// call (0xc0000005) and leave an orphaned adapter. EOF lets main.go
|
|
106
|
+
// return normally and run its deferred session/adapter Close(). Keep
|
|
107
|
+
// SIGTERM/SIGKILL only as a fallback for a wedged helper.
|
|
108
|
+
if (helper.stdin && !helper.stdin.destroyed)
|
|
109
|
+
helper.stdin.end();
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
if (helper.exitCode === null && helper.signalCode === null) {
|
|
112
|
+
helper.kill("SIGTERM");
|
|
113
|
+
}
|
|
114
|
+
}, 1000).unref?.();
|
|
85
115
|
});
|
|
86
116
|
}
|
|
87
117
|
this.emit("closed");
|
|
@@ -178,8 +208,9 @@ export class TunDevice extends EventEmitter {
|
|
|
178
208
|
async openReal() {
|
|
179
209
|
const helperPath = this.helperPath || this.findHelperBinary();
|
|
180
210
|
if (!helperPath || !existsSync(helperPath)) {
|
|
211
|
+
const expected = helperBinaryRelativePath(process.platform, process.arch);
|
|
181
212
|
throw new Error(`TUN helper binary not found: ${helperPath || "(none)"}\n` +
|
|
182
|
-
`
|
|
213
|
+
`Expected package helper: ${expected || `${process.platform}/${process.arch} (unsupported)`}`);
|
|
183
214
|
}
|
|
184
215
|
// On Linux the helper creates a FIXED-name interface (agentnet0). If a
|
|
185
216
|
// prior helper died uncleanly — e.g. "read tun: file descriptor in bad
|
|
@@ -301,27 +332,13 @@ export class TunDevice extends EventEmitter {
|
|
|
301
332
|
resolve(moduleDir, "../../../bin"),
|
|
302
333
|
resolve(process.cwd(), "bin"),
|
|
303
334
|
];
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (platform === "linux" && arch === "x64") {
|
|
308
|
-
suffix = "linux-amd64";
|
|
309
|
-
}
|
|
310
|
-
else if (platform === "darwin" && arch === "arm64") {
|
|
311
|
-
suffix = "darwin-arm64";
|
|
312
|
-
}
|
|
313
|
-
else if (platform === "darwin" && arch === "x64") {
|
|
314
|
-
suffix = "darwin-amd64";
|
|
315
|
-
}
|
|
316
|
-
else if (platform === "linux" && arch === "arm64") {
|
|
317
|
-
suffix = "linux-arm64";
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
this.logger.warn(`Unsupported platform: ${platform}/${arch}`);
|
|
335
|
+
const relativePath = helperBinaryRelativePath(process.platform, process.arch);
|
|
336
|
+
if (!relativePath) {
|
|
337
|
+
this.logger.warn(`Unsupported platform: ${process.platform}/${process.arch}`);
|
|
321
338
|
return null;
|
|
322
339
|
}
|
|
323
340
|
for (const dir of candidates) {
|
|
324
|
-
const path = resolve(dir,
|
|
341
|
+
const path = resolve(dir, relativePath);
|
|
325
342
|
if (existsSync(path)) {
|
|
326
343
|
return path;
|
|
327
344
|
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -77,8 +77,8 @@ dora:
|
|
|
77
77
|
|
|
78
78
|
### `network`
|
|
79
79
|
|
|
80
|
-
- **`interface`** — TUN device name on Linux
|
|
81
|
-
assigned `utun*` regardless of this field.
|
|
80
|
+
- **`interface`** — TUN device name on Linux and native Windows (Wintun).
|
|
81
|
+
macOS uses kernel-assigned `utun*` regardless of this field.
|
|
82
82
|
- **`ip`** — fallback IP if dora can't be reached. **When dora is
|
|
83
83
|
enabled and reachable, dora's allocation overrides this.**
|
|
84
84
|
- **`subnet`** — `/16` is the only tested size today. Don't change
|