@trim21/personal-pi-extensions 0.1.444 → 0.1.446
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/package.json +1 -1
- package/src/bwrap/core.ts +0 -2
- package/src/bwrap/holder.js +17 -4
- package/src/bwrap/holder.ts +32 -7
- package/src/bwrap/network-stack.ts +79 -69
- package/src/lib/proc.ts +20 -0
package/package.json
CHANGED
package/src/bwrap/core.ts
CHANGED
|
@@ -380,7 +380,6 @@ function killChild(child: ChildProcess): void {
|
|
|
380
380
|
/** 网络栈子进程输出转发通道,仅用于诊断(默认丢弃)。 */
|
|
381
381
|
export interface NetworkStackLog {
|
|
382
382
|
holder?: (chunk: string) => void;
|
|
383
|
-
slirp?: (chunk: string) => void;
|
|
384
383
|
}
|
|
385
384
|
|
|
386
385
|
/** 为 net-allowlist 模式创建网络栈;非该模式返回 undefined。每次命令现建现停。 */
|
|
@@ -395,7 +394,6 @@ export async function createNetworkStack(
|
|
|
395
394
|
mihomoPath: findMihomo(resolved.mihomoPath),
|
|
396
395
|
slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
|
|
397
396
|
...(log?.holder && { onHolderOutput: log.holder }),
|
|
398
|
-
...(log?.slirp && { onSlirpOutput: log.slirp }),
|
|
399
397
|
});
|
|
400
398
|
}
|
|
401
399
|
|
package/src/bwrap/holder.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
4
|
const args = process.argv.slice(2);
|
|
5
|
-
const
|
|
5
|
+
const configBase64 = args[0];
|
|
6
6
|
const mihomoPath = args[1];
|
|
7
|
-
|
|
7
|
+
const slirp4netnsPath = args[2];
|
|
8
|
+
const tunMtu = Number(args[3]);
|
|
9
|
+
if (!configBase64 || !mihomoPath || !slirp4netnsPath || !tunMtu) {
|
|
8
10
|
process.exit(2);
|
|
9
11
|
}
|
|
12
|
+
process.chdir("/");
|
|
10
13
|
function tap0Exists() {
|
|
11
14
|
return new Promise((resolve) => {
|
|
12
15
|
const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
|
|
@@ -22,8 +25,18 @@ async function waitFor(attempts, delayMs, predicate) {
|
|
|
22
25
|
return false;
|
|
23
26
|
}
|
|
24
27
|
async function main() {
|
|
28
|
+
process.stdin.resume();
|
|
29
|
+
process.stdin.on("end", () => process.exit(0));
|
|
30
|
+
const status = await readFile("/proc/self/status", "utf8");
|
|
31
|
+
const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
|
|
32
|
+
if (!hostPid) {
|
|
33
|
+
throw new Error("Failed to resolve host pid from NSpid");
|
|
34
|
+
}
|
|
35
|
+
spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
|
|
36
|
+
stdio: ["ignore", "inherit", "inherit"]
|
|
37
|
+
});
|
|
25
38
|
await waitFor(100, 100, tap0Exists);
|
|
26
|
-
const mihomo = spawn(mihomoPath, ["-
|
|
39
|
+
const mihomo = spawn(mihomoPath, ["-config", configBase64], {
|
|
27
40
|
stdio: ["ignore", "inherit", "inherit"]
|
|
28
41
|
});
|
|
29
42
|
const stop = () => {
|
package/src/bwrap/holder.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
5
|
|
|
6
|
-
// 用法:unshare -
|
|
6
|
+
// 用法:unshare -Urnp --fork --kill-child node holder.ts <config-base64> <mihomo> <slirp4netns> <mtu>
|
|
7
|
+
// 本进程是 pid namespace 的 init:无论以何种方式退出(含 SIGKILL),内核都会清理该 pid ns 内的
|
|
8
|
+
// slirp4netns / mihomo,userns/netns 引用随之归零。
|
|
7
9
|
const args = process.argv.slice(2);
|
|
8
|
-
const
|
|
10
|
+
const configBase64 = args[0];
|
|
9
11
|
const mihomoPath = args[1];
|
|
10
|
-
|
|
12
|
+
const slirp4netnsPath = args[2];
|
|
13
|
+
const tunMtu = Number(args[3]);
|
|
14
|
+
if (!configBase64 || !mihomoPath || !slirp4netnsPath || !tunMtu) {
|
|
11
15
|
process.exit(2);
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
// mihomo 不写工作目录(配置经 -config 直传),切到根目录防止在宿主 cwd 意外落盘
|
|
19
|
+
process.chdir("/");
|
|
20
|
+
|
|
14
21
|
function tap0Exists(): Promise<boolean> {
|
|
15
22
|
return new Promise((resolve) => {
|
|
16
23
|
const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
|
|
@@ -32,11 +39,29 @@ async function waitFor(
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
async function main(): Promise<void> {
|
|
35
|
-
//
|
|
36
|
-
|
|
42
|
+
// 宿主(pi)进程死亡检测:宿主以任何方式退出(含 SIGKILL)都会关闭它持有的 stdin 写端,
|
|
43
|
+
// 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns)。
|
|
44
|
+
process.stdin.resume();
|
|
45
|
+
process.stdin.on("end", () => process.exit(0));
|
|
46
|
+
|
|
47
|
+
// 宿主视角 pid(NSpid 第一项):/proc 挂载是宿主的(-Urnp 不含 -m,宿主视角下
|
|
48
|
+
// /proc/1 是 systemd 而非本 pid ns 的 init),slirp4netns 的 setns 必须走宿主 /proc
|
|
49
|
+
const status = await readFile("/proc/self/status", "utf8");
|
|
50
|
+
const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
|
|
51
|
+
if (!hostPid) {
|
|
52
|
+
throw new Error("Failed to resolve host pid from NSpid");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// slirp4netns 提供 egress:本进程已在目标 userns/netns 内,setns 到自身 netns(no-op);
|
|
56
|
+
// 日志 inherit 到 holder 的 stdout/stderr,宿主侧诊断(--verbose)可见
|
|
57
|
+
spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
|
|
58
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
59
|
+
});
|
|
37
60
|
|
|
38
61
|
// mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪
|
|
39
|
-
|
|
62
|
+
await waitFor(100, 100, tap0Exists);
|
|
63
|
+
|
|
64
|
+
const mihomo = spawn(mihomoPath, ["-config", configBase64], {
|
|
40
65
|
stdio: ["ignore", "inherit", "inherit"],
|
|
41
66
|
});
|
|
42
67
|
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
2
|
+
import { readFile, readlink } from "node:fs/promises";
|
|
5
3
|
import { fileURLToPath } from "node:url";
|
|
6
4
|
|
|
5
|
+
import { forEachLine } from "../lib/proc.js";
|
|
7
6
|
import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
|
|
8
7
|
|
|
9
8
|
/** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
|
|
@@ -26,8 +25,6 @@ export interface NetworkStackOptions {
|
|
|
26
25
|
* 因此启动失败时只剩 "exited before mihomo started",诊断需要它。
|
|
27
26
|
*/
|
|
28
27
|
readonly onHolderOutput?: (chunk: string) => void;
|
|
29
|
-
/** slirp4netns 输出透传;不传时其 stdio 保持 ignore。 */
|
|
30
|
-
readonly onSlirpOutput?: (chunk: string) => void;
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
export interface NetworkStackExecOptions {
|
|
@@ -50,22 +47,17 @@ const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
|
|
|
50
47
|
// ip 通常位于 /usr/sbin 或 /sbin,进程默认 PATH 不含它们;node 则依赖宿主完整 PATH
|
|
51
48
|
const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
|
|
52
49
|
|
|
53
|
-
function killProcess(pid: number | undefined): void {
|
|
50
|
+
function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
54
51
|
if (!pid) return;
|
|
55
52
|
try {
|
|
56
|
-
process.kill(pid,
|
|
53
|
+
process.kill(pid, signal);
|
|
57
54
|
} catch {
|
|
58
55
|
// 已退出
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
* holder/slirp 持有 userns/netns 引用,进程不退 ns 就不会释放。
|
|
65
|
-
*/
|
|
66
|
-
async function terminateProcess(pid: number | undefined, timeoutMs = 2000): Promise<void> {
|
|
67
|
-
if (!pid) return;
|
|
68
|
-
killProcess(pid);
|
|
59
|
+
/** 轮询等待进程退出(进程消失即返回)。 */
|
|
60
|
+
async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
|
|
69
61
|
const deadline = Date.now() + timeoutMs;
|
|
70
62
|
while (Date.now() < deadline) {
|
|
71
63
|
try {
|
|
@@ -75,10 +67,15 @@ async function terminateProcess(pid: number | undefined, timeoutMs = 2000): Prom
|
|
|
75
67
|
}
|
|
76
68
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
77
69
|
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 读取进程的直接子进程 pid:--kill-child 只转发信号给 fork 的子进程,兜底直接 SIGKILL 用。 */
|
|
73
|
+
async function readChildPids(pid: number): Promise<number[]> {
|
|
78
74
|
try {
|
|
79
|
-
|
|
75
|
+
const content = await readFile(`/proc/${pid}/task/${pid}/children`, "utf8");
|
|
76
|
+
return content.trim().split(/\s+/).filter(Boolean).map(Number);
|
|
80
77
|
} catch {
|
|
81
|
-
|
|
78
|
+
return [];
|
|
82
79
|
}
|
|
83
80
|
}
|
|
84
81
|
|
|
@@ -97,7 +94,7 @@ export async function resolveDnsServers(): Promise<string[]> {
|
|
|
97
94
|
return ipv4;
|
|
98
95
|
}
|
|
99
96
|
|
|
100
|
-
/** 等待目标进程进入新的 user namespace(unshare
|
|
97
|
+
/** 等待目标进程进入新的 user namespace(unshare 完成),返回后命令才能 nsenter 进入。 */
|
|
101
98
|
async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
|
|
102
99
|
const self = await readlink("/proc/self/ns/user");
|
|
103
100
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -121,16 +118,23 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
|
|
|
121
118
|
settled = true;
|
|
122
119
|
reject(new Error("Timed out waiting for mihomo to start"));
|
|
123
120
|
}, timeoutMs);
|
|
124
|
-
const
|
|
121
|
+
const onReady = (): void => {
|
|
125
122
|
if (settled) return;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
resolve();
|
|
130
|
-
}
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
resolve();
|
|
131
126
|
};
|
|
132
|
-
|
|
133
|
-
holder.
|
|
127
|
+
// 按行扫描:mihomo 日志行可能跨 data chunk,由 forEachLine 负责拼接
|
|
128
|
+
for (const stream of [holder.stdout, holder.stderr]) {
|
|
129
|
+
if (stream) {
|
|
130
|
+
// 不提前停止:holder 生命周期内持续消费输出,避免流无消费者触发背压
|
|
131
|
+
void forEachLine(stream, (line) => {
|
|
132
|
+
if (line.includes("Tun adapter listening")) onReady();
|
|
133
|
+
}).catch(() => {
|
|
134
|
+
// 就绪判定由超时与 holder exit 兜底,流的 error 忽略
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
134
138
|
holder.once("exit", (code) => {
|
|
135
139
|
if (settled) return;
|
|
136
140
|
settled = true;
|
|
@@ -170,21 +174,16 @@ export interface NetworkStack {
|
|
|
170
174
|
stop(): Promise<void>;
|
|
171
175
|
/** holder pid:可 `nsenter -U -n --preserve-credentials -t <pid>` 手动进入该 netns 排查。 */
|
|
172
176
|
readonly holderPid: number;
|
|
173
|
-
/** 生成的 mihomo 配置路径(stop() 后随临时目录一起删除)。 */
|
|
174
|
-
readonly configPath: string;
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
interface NetworkStackState {
|
|
178
180
|
holderPid: number;
|
|
179
|
-
slirpPid: number | undefined;
|
|
180
|
-
directory: string;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill
|
|
183
|
+
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程。 */
|
|
184
184
|
const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
185
|
-
|
|
185
|
+
// SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程
|
|
186
186
|
killProcess(state.holderPid);
|
|
187
|
-
void rm(state.directory, { recursive: true, force: true }).catch(() => false);
|
|
188
187
|
});
|
|
189
188
|
|
|
190
189
|
/**
|
|
@@ -199,24 +198,40 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
199
198
|
mihomoPath,
|
|
200
199
|
slirp4netnsPath,
|
|
201
200
|
onHolderOutput: holderOutput,
|
|
202
|
-
onSlirpOutput: slirpOutput,
|
|
203
201
|
} = options;
|
|
204
|
-
|
|
205
|
-
const configPath = join(directory, "mihomo.json");
|
|
202
|
+
// mihomo 支持 -config 直接接收 base64 配置,无需写配置文件中转
|
|
206
203
|
const config = generateMihomoConfig({ allowlist, dnsServers });
|
|
207
|
-
|
|
204
|
+
const configBase64 = Buffer.from(JSON.stringify(config)).toString("base64");
|
|
208
205
|
|
|
209
206
|
let holder: ChildProcess | undefined;
|
|
210
|
-
let slirp: ChildProcess | undefined;
|
|
211
207
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
208
|
+
// unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
|
|
209
|
+
// 内核都会清理 pid ns 内全部进程(slirp4netns/mihomo),ns 引用随之归零;
|
|
210
|
+
// --kill-child=SIGTERM:宿主侧 SIGTERM unshare 时转发给 init 走优雅退出
|
|
211
|
+
holder = spawn(
|
|
212
|
+
"unshare",
|
|
213
|
+
[
|
|
214
|
+
"-Urnp",
|
|
215
|
+
"--fork",
|
|
216
|
+
"--kill-child=SIGTERM",
|
|
217
|
+
"--",
|
|
218
|
+
"node",
|
|
219
|
+
HOLDER_PATH,
|
|
220
|
+
configBase64,
|
|
221
|
+
mihomoPath,
|
|
222
|
+
slirp4netnsPath,
|
|
223
|
+
String(TUN_MTU),
|
|
224
|
+
],
|
|
225
|
+
{
|
|
226
|
+
env: {
|
|
227
|
+
HOME: process.env.HOME ?? "",
|
|
228
|
+
PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
|
|
229
|
+
},
|
|
230
|
+
// stdin 保持 pipe:本进程持有写端,进程退出(含 SIGKILL)时内核关闭 fd,
|
|
231
|
+
// holder 读到 EOF 即自杀(init 退出 → 内核清理 pid ns)
|
|
232
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
216
233
|
},
|
|
217
|
-
|
|
218
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
219
|
-
});
|
|
234
|
+
);
|
|
220
235
|
forwardOutput(holder, holderOutput);
|
|
221
236
|
if (holder.pid === undefined) {
|
|
222
237
|
throw new Error("Failed to start network namespace holder");
|
|
@@ -224,24 +239,9 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
224
239
|
const holderPid = holder.pid;
|
|
225
240
|
await waitForNewUserns(holderPid);
|
|
226
241
|
|
|
227
|
-
slirp = spawn(
|
|
228
|
-
slirp4netnsPath,
|
|
229
|
-
[
|
|
230
|
-
"-c",
|
|
231
|
-
`--mtu=${TUN_MTU}`,
|
|
232
|
-
`--userns-path=/proc/${holderPid}/ns/user`,
|
|
233
|
-
"--netns-type=pid",
|
|
234
|
-
String(holderPid),
|
|
235
|
-
"tap0",
|
|
236
|
-
],
|
|
237
|
-
// 默认丢弃 slirp4netns 日志;诊断时改为管道转发
|
|
238
|
-
{ stdio: slirpOutput ? ["ignore", "pipe", "pipe"] : "ignore" },
|
|
239
|
-
);
|
|
240
|
-
forwardOutput(slirp, slirpOutput);
|
|
241
|
-
const slirpPid = slirp.pid;
|
|
242
242
|
await waitForMihomoStarted(holder);
|
|
243
243
|
|
|
244
|
-
const state: NetworkStackState = { holderPid
|
|
244
|
+
const state: NetworkStackState = { holderPid };
|
|
245
245
|
const stack: NetworkStack = {
|
|
246
246
|
exec: async (execOptions: NetworkStackExecOptions) => {
|
|
247
247
|
const child = spawn(
|
|
@@ -310,21 +310,31 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
310
310
|
});
|
|
311
311
|
},
|
|
312
312
|
stop: async () => {
|
|
313
|
-
await
|
|
314
|
-
|
|
315
|
-
|
|
313
|
+
const children = await readChildPids(state.holderPid);
|
|
314
|
+
// SIGTERM unshare → --kill-child 转发 SIGTERM 给 init(pid ns 的 pid 1),
|
|
315
|
+
// init 优雅停 mihomo 后退出,内核清理 pid ns 内全部进程,ns 引用随之归零
|
|
316
|
+
killProcess(state.holderPid);
|
|
317
|
+
await waitForExit(state.holderPid);
|
|
318
|
+
// 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
|
|
319
|
+
for (const pid of children) {
|
|
320
|
+
killProcess(pid, "SIGKILL");
|
|
321
|
+
}
|
|
316
322
|
},
|
|
317
323
|
holderPid,
|
|
318
|
-
configPath,
|
|
319
324
|
};
|
|
320
325
|
stackFinalizer.register(stack, state);
|
|
321
326
|
return stack;
|
|
322
327
|
} catch (error) {
|
|
323
|
-
// 失败清理:holder
|
|
324
|
-
//
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
+
// 失败清理:holder(unshare)的 SIGTERM 经 --kill-child 转发给 init,
|
|
329
|
+
// init 退出时内核清理 pid ns 内全部进程;调用方拿不到 stack,这里只能靠自己
|
|
330
|
+
if (holder?.pid) {
|
|
331
|
+
const children = await readChildPids(holder.pid);
|
|
332
|
+
killProcess(holder.pid);
|
|
333
|
+
await waitForExit(holder.pid);
|
|
334
|
+
for (const pid of children) {
|
|
335
|
+
killProcess(pid, "SIGKILL");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
328
338
|
throw error;
|
|
329
339
|
}
|
|
330
340
|
}
|
package/src/lib/proc.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按行消费输出流:data 事件按 buffer 边界切分,行可能跨多个 chunk,
|
|
3
|
+
* 这里拼接缓冲后按 \n 逐行回调(回调不含换行符)。
|
|
4
|
+
* 回调返回 false 可提前停止消费;流的 error 会以 rejection 形式抛出。
|
|
5
|
+
*/
|
|
6
|
+
export async function forEachLine(
|
|
7
|
+
stream: NodeJS.ReadableStream,
|
|
8
|
+
callback: (line: string) => boolean | void,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
let buffer = "";
|
|
11
|
+
for await (const chunk of stream) {
|
|
12
|
+
buffer += chunk.toString();
|
|
13
|
+
let newline: number;
|
|
14
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
15
|
+
const line = buffer.slice(0, newline);
|
|
16
|
+
buffer = buffer.slice(newline + 1);
|
|
17
|
+
if (callback(line) === false) return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|