@trim21/personal-pi-extensions 0.0.423 → 0.0.425
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 +3 -1
- package/src/bwrap/core.ts +111 -15
- package/src/bwrap/holder.js +61 -0
- package/src/bwrap/holder.ts +79 -0
- package/src/bwrap/network-stack.ts +230 -0
- package/src/bwrap/runtime.ts +55 -1
- package/src/bwrap/singbox-config.ts +77 -0
- package/src/lib/parse-with-schema.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.425",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"format": "prettier --write .",
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:coverage": "vitest run --coverage",
|
|
26
|
+
"build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
|
|
26
27
|
"prepare": "husky"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"@types/turndown": "^5.0.6",
|
|
41
42
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
42
43
|
"@vitest/coverage-v8": "^4.1.10",
|
|
44
|
+
"esbuild": "^0.28.2",
|
|
43
45
|
"eslint": "^10.8.1",
|
|
44
46
|
"eslint-config-prettier": "10.1.8",
|
|
45
47
|
"eslint-plugin-erasable-syntax-only": "0.7.1",
|
package/src/bwrap/core.ts
CHANGED
|
@@ -9,12 +9,20 @@ import { type BashOperations, getAgentDir, getShellConfig } from "@earendil-work
|
|
|
9
9
|
import { type Static, Type } from "typebox";
|
|
10
10
|
import { Value } from "typebox/value";
|
|
11
11
|
|
|
12
|
+
import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
12
13
|
import { expandHome } from "../lib/path.js";
|
|
13
14
|
import { type ApprovalRule } from "./approval-rules.js";
|
|
15
|
+
import { type NetworkStack, resolveDnsServers, startNetworkStack } from "./network-stack.js";
|
|
14
16
|
|
|
15
17
|
const PROTECTED_DIRS = [".pi", ".agent"];
|
|
16
18
|
|
|
17
|
-
export const BWRAP_MODES = [
|
|
19
|
+
export const BWRAP_MODES = [
|
|
20
|
+
"allow-all",
|
|
21
|
+
"workspace-write",
|
|
22
|
+
"allow-net",
|
|
23
|
+
"net-allowlist",
|
|
24
|
+
"readonly",
|
|
25
|
+
] as const;
|
|
18
26
|
|
|
19
27
|
export type BwrapMode = (typeof BWRAP_MODES)[number];
|
|
20
28
|
|
|
@@ -25,6 +33,9 @@ const bwrapConfigProperties = {
|
|
|
25
33
|
extraWritablePaths: Type.Array(Type.String()),
|
|
26
34
|
tmpfsPaths: Type.Array(Type.String()),
|
|
27
35
|
extraArgs: Type.Array(Type.String()),
|
|
36
|
+
networkAllowlist: Type.Array(Type.String()),
|
|
37
|
+
singBoxPath: Type.Optional(Type.String()),
|
|
38
|
+
slirp4netnsPath: Type.Optional(Type.String()),
|
|
28
39
|
approvalRules: Type.Optional(
|
|
29
40
|
Type.Array(
|
|
30
41
|
Type.Object(
|
|
@@ -32,18 +43,20 @@ const bwrapConfigProperties = {
|
|
|
32
43
|
action: StringEnum(["allow", "deny"] as const),
|
|
33
44
|
pattern: Type.String({ description: '命令模式,如 "git push *"、"npm install *"' }),
|
|
34
45
|
},
|
|
35
|
-
{ additionalProperties:
|
|
46
|
+
{ additionalProperties: true },
|
|
36
47
|
),
|
|
37
48
|
),
|
|
38
49
|
),
|
|
39
50
|
};
|
|
40
51
|
|
|
52
|
+
// 配置文件容忍未知字段:schema 之外的字段(如新版本扩展新增的配置)会被忽略,
|
|
53
|
+
// 避免整个 bwrap 配置因单个未知字段失效;已声明字段仍做类型/取值校验。
|
|
41
54
|
export const bwrapConfigSchema = Type.Object(bwrapConfigProperties, {
|
|
42
|
-
additionalProperties:
|
|
55
|
+
additionalProperties: true,
|
|
43
56
|
});
|
|
44
57
|
|
|
45
58
|
export const bwrapConfigFileSchema = Type.Partial(bwrapConfigSchema, {
|
|
46
|
-
additionalProperties:
|
|
59
|
+
additionalProperties: true,
|
|
47
60
|
});
|
|
48
61
|
|
|
49
62
|
export type BwrapConfig = Static<typeof bwrapConfigSchema>;
|
|
@@ -58,6 +71,10 @@ export interface ResolvedBwrap {
|
|
|
58
71
|
extraWritablePaths: string[];
|
|
59
72
|
tmpfsPaths: string[];
|
|
60
73
|
extraArgs: string[];
|
|
74
|
+
/** per-domain 过滤的允许域名(非空 = 启用 sing-box 网络过滤)。 */
|
|
75
|
+
networkAllowlist: string[];
|
|
76
|
+
singBoxPath?: string;
|
|
77
|
+
slirp4netnsPath?: string;
|
|
61
78
|
/** 全权限执行的自动审批规则(allow/deny 命令模式)。 */
|
|
62
79
|
approvalRules: ApprovalRule[];
|
|
63
80
|
}
|
|
@@ -68,6 +85,7 @@ const DEFAULT_CONFIG: BwrapConfig = {
|
|
|
68
85
|
extraWritablePaths: [],
|
|
69
86
|
tmpfsPaths: [],
|
|
70
87
|
extraArgs: [],
|
|
88
|
+
networkAllowlist: [],
|
|
71
89
|
};
|
|
72
90
|
|
|
73
91
|
export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
@@ -78,6 +96,9 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
78
96
|
extraWritablePaths: config.extraWritablePaths,
|
|
79
97
|
tmpfsPaths: config.tmpfsPaths ?? [],
|
|
80
98
|
extraArgs: config.extraArgs ?? [],
|
|
99
|
+
networkAllowlist: config.networkAllowlist ?? [],
|
|
100
|
+
singBoxPath: config.singBoxPath,
|
|
101
|
+
slirp4netnsPath: config.slirp4netnsPath,
|
|
81
102
|
approvalRules: config.approvalRules ?? [],
|
|
82
103
|
};
|
|
83
104
|
switch (config.mode) {
|
|
@@ -90,6 +111,9 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
90
111
|
case "allow-net": {
|
|
91
112
|
return { ...base, bwrapEnabled: true, network: true };
|
|
92
113
|
}
|
|
114
|
+
case "net-allowlist": {
|
|
115
|
+
return { ...base, bwrapEnabled: true, network: true };
|
|
116
|
+
}
|
|
93
117
|
case "readonly": {
|
|
94
118
|
return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
|
|
95
119
|
}
|
|
@@ -115,6 +139,9 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
|
|
|
115
139
|
extraWritablePaths: [...base.extraWritablePaths, ...(overrides.extraWritablePaths ?? [])],
|
|
116
140
|
tmpfsPaths: overrides.tmpfsPaths ?? base.tmpfsPaths,
|
|
117
141
|
extraArgs: overrides.extraArgs ?? base.extraArgs,
|
|
142
|
+
networkAllowlist: overrides.networkAllowlist ?? base.networkAllowlist,
|
|
143
|
+
singBoxPath: overrides.singBoxPath ?? base.singBoxPath,
|
|
144
|
+
slirp4netnsPath: overrides.slirp4netnsPath ?? base.slirp4netnsPath,
|
|
118
145
|
approvalRules: [...(base.approvalRules ?? []), ...(overrides.approvalRules ?? [])],
|
|
119
146
|
};
|
|
120
147
|
}
|
|
@@ -127,9 +154,10 @@ function parseBwrapConfigFile(path: string): BwrapConfigFile {
|
|
|
127
154
|
throw new Error(`Invalid bwrap configuration at ${path}: ${String(error)}`, { cause: error });
|
|
128
155
|
}
|
|
129
156
|
try {
|
|
130
|
-
return
|
|
157
|
+
return parseWithSchema(bwrapConfigFileSchema, raw);
|
|
131
158
|
} catch (error) {
|
|
132
|
-
|
|
159
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
160
|
+
throw new Error(`Invalid bwrap configuration at ${path}: ${detail}`, { cause: error });
|
|
133
161
|
}
|
|
134
162
|
}
|
|
135
163
|
|
|
@@ -190,6 +218,41 @@ export function findBwrap(override?: string): string {
|
|
|
190
218
|
return findDefaultBwrap();
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
function findCommandInPath(name: string, hint: string): string {
|
|
222
|
+
const pathEnv = process.env.PATH ?? "";
|
|
223
|
+
for (const directory of pathEnv.split(delimiter)) {
|
|
224
|
+
const candidate = join(directory, name);
|
|
225
|
+
if (existsSync(candidate)) return candidate;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(hint);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function findSingBox(override?: string): string {
|
|
231
|
+
if (override) {
|
|
232
|
+
if (!existsSync(override)) {
|
|
233
|
+
throw new Error(`sing-box not found at configured path: ${override}`);
|
|
234
|
+
}
|
|
235
|
+
return override;
|
|
236
|
+
}
|
|
237
|
+
return findCommandInPath(
|
|
238
|
+
"sing-box",
|
|
239
|
+
"sing-box not found in PATH. Install it from https://sing-box.sagernet.org/",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function findSlirp4netns(override?: string): string {
|
|
244
|
+
if (override) {
|
|
245
|
+
if (!existsSync(override)) {
|
|
246
|
+
throw new Error(`slirp4netns not found at configured path: ${override}`);
|
|
247
|
+
}
|
|
248
|
+
return override;
|
|
249
|
+
}
|
|
250
|
+
return findCommandInPath(
|
|
251
|
+
"slirp4netns",
|
|
252
|
+
"slirp4netns not found in PATH. Install it from https://github.com/rootless-containers/slirp4netns",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
193
256
|
/** 扫描 .git 时跳过的目录:包/依赖/构建产物,嵌套 git 仓库几乎不会出现在这里。 */
|
|
194
257
|
const GIT_DIR_SCAN_SKIP = new Set([
|
|
195
258
|
"node_modules",
|
|
@@ -310,6 +373,19 @@ function killChild(child: ChildProcess): void {
|
|
|
310
373
|
}
|
|
311
374
|
}
|
|
312
375
|
|
|
376
|
+
/** 为 net-allowlist 模式创建常驻网络栈;非该模式返回 undefined。 */
|
|
377
|
+
export async function createNetworkStack(
|
|
378
|
+
resolved: ResolvedBwrap,
|
|
379
|
+
): Promise<NetworkStack | undefined> {
|
|
380
|
+
if (!resolved.network || resolved.networkAllowlist.length === 0) return undefined;
|
|
381
|
+
return startNetworkStack({
|
|
382
|
+
allowlist: resolved.networkAllowlist,
|
|
383
|
+
dnsServers: await resolveDnsServers(),
|
|
384
|
+
singBoxPath: findSingBox(resolved.singBoxPath),
|
|
385
|
+
slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
313
389
|
/**
|
|
314
390
|
* @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
|
|
315
391
|
* 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
|
|
@@ -317,6 +393,7 @@ function killChild(child: ChildProcess): void {
|
|
|
317
393
|
export function createBwrapBashOperations(
|
|
318
394
|
resolved: ResolvedBwrap,
|
|
319
395
|
workspace: string,
|
|
396
|
+
networkStack?: NetworkStack,
|
|
320
397
|
): BashOperations {
|
|
321
398
|
// 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
|
|
322
399
|
const shell = getShellConfig().shell;
|
|
@@ -333,7 +410,6 @@ export function createBwrapBashOperations(
|
|
|
333
410
|
throw new Error("HOME is not set; refusing to run bash in a clean environment");
|
|
334
411
|
}
|
|
335
412
|
|
|
336
|
-
const seccompFd = resolved.network ? undefined : getSeccompFd();
|
|
337
413
|
const baseArgs = [
|
|
338
414
|
"--ro-bind",
|
|
339
415
|
"/",
|
|
@@ -344,6 +420,33 @@ export function createBwrapBashOperations(
|
|
|
344
420
|
"--proc",
|
|
345
421
|
"/proc",
|
|
346
422
|
];
|
|
423
|
+
const env = {
|
|
424
|
+
HOME: home,
|
|
425
|
+
SHELL: "/bin/bash",
|
|
426
|
+
TERM: "dumb",
|
|
427
|
+
LANG: "C.UTF-8",
|
|
428
|
+
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
429
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
if (resolved.network && resolved.networkAllowlist.length > 0) {
|
|
433
|
+
if (!networkStack) {
|
|
434
|
+
throw new Error("Network stack is not initialized for net-allowlist mode");
|
|
435
|
+
}
|
|
436
|
+
return networkStack.exec({
|
|
437
|
+
command,
|
|
438
|
+
cwd,
|
|
439
|
+
bwrapPath: findBwrap(resolved.bwrapPath),
|
|
440
|
+
bwrapArgs: baseArgs,
|
|
441
|
+
shell,
|
|
442
|
+
env,
|
|
443
|
+
onData,
|
|
444
|
+
signal,
|
|
445
|
+
timeout,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const seccompFd = resolved.network ? undefined : getSeccompFd();
|
|
347
450
|
const child = spawn(
|
|
348
451
|
findBwrap(resolved.bwrapPath),
|
|
349
452
|
seccompFd === undefined
|
|
@@ -356,14 +459,7 @@ export function createBwrapBashOperations(
|
|
|
356
459
|
seccompFd === undefined
|
|
357
460
|
? ["ignore", "pipe", "pipe"]
|
|
358
461
|
: ["ignore", "pipe", "pipe", seccompFd],
|
|
359
|
-
env
|
|
360
|
-
HOME: home,
|
|
361
|
-
SHELL: "/bin/bash",
|
|
362
|
-
TERM: "dumb",
|
|
363
|
-
LANG: "C.UTF-8",
|
|
364
|
-
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
365
|
-
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
366
|
-
},
|
|
462
|
+
env,
|
|
367
463
|
},
|
|
368
464
|
);
|
|
369
465
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const configPath = args[0];
|
|
6
|
+
const singBoxPath = args[1];
|
|
7
|
+
const readyFile = args[2];
|
|
8
|
+
if (!configPath || !singBoxPath || !readyFile) {
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
function tap0Exists() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
|
|
14
|
+
child.once("exit", (code) => resolve(code === 0));
|
|
15
|
+
child.once("error", () => resolve(false));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function waitFor(attempts, delayMs, predicate) {
|
|
19
|
+
for (let i = 0; i < attempts; i++) {
|
|
20
|
+
if (await predicate()) return true;
|
|
21
|
+
await sleep(delayMs);
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
async function main() {
|
|
26
|
+
await waitFor(100, 100, tap0Exists);
|
|
27
|
+
const logPath = `${configPath}.log`;
|
|
28
|
+
const logFd = await open(logPath, "w");
|
|
29
|
+
const singbox = spawn(singBoxPath, ["run", "-c", configPath], {
|
|
30
|
+
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
31
|
+
stdio: ["ignore", logFd.fd, logFd.fd]
|
|
32
|
+
});
|
|
33
|
+
const stop = () => {
|
|
34
|
+
singbox.kill("SIGTERM");
|
|
35
|
+
};
|
|
36
|
+
process.on("SIGTERM", () => {
|
|
37
|
+
stop();
|
|
38
|
+
process.exit(0);
|
|
39
|
+
});
|
|
40
|
+
process.on("SIGINT", () => {
|
|
41
|
+
stop();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
await waitFor(200, 100, async () => {
|
|
45
|
+
try {
|
|
46
|
+
const log = await readFile(logPath, "utf8");
|
|
47
|
+
return log.includes("sing-box started");
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
await sleep(4e3);
|
|
53
|
+
await writeFile(readyFile, "");
|
|
54
|
+
await new Promise((resolve) => singbox.once("exit", () => resolve()));
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await main();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(error);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { open, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
// 用法:unshare -Urn node holder.ts <config> <singbox> <ready-file>
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const configPath = args[0];
|
|
9
|
+
const singBoxPath = args[1];
|
|
10
|
+
const readyFile = args[2];
|
|
11
|
+
if (!configPath || !singBoxPath || !readyFile) {
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tap0Exists(): Promise<boolean> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
|
|
18
|
+
child.once("exit", (code) => resolve(code === 0));
|
|
19
|
+
child.once("error", () => resolve(false));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function waitFor(
|
|
24
|
+
attempts: number,
|
|
25
|
+
delayMs: number,
|
|
26
|
+
predicate: () => Promise<boolean>,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
for (let i = 0; i < attempts; i++) {
|
|
29
|
+
if (await predicate()) return true;
|
|
30
|
+
await sleep(delayMs);
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
// slirp4netns 由宿主侧 spawn,这里等它建好 tap0
|
|
37
|
+
await waitFor(100, 100, tap0Exists);
|
|
38
|
+
|
|
39
|
+
const logPath = `${configPath}.log`;
|
|
40
|
+
const logFd = await open(logPath, "w");
|
|
41
|
+
const singbox = spawn(singBoxPath, ["run", "-c", configPath], {
|
|
42
|
+
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
43
|
+
stdio: ["ignore", logFd.fd, logFd.fd],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const stop = (): void => {
|
|
47
|
+
singbox.kill("SIGTERM");
|
|
48
|
+
};
|
|
49
|
+
process.on("SIGTERM", () => {
|
|
50
|
+
stop();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
process.on("SIGINT", () => {
|
|
54
|
+
stop();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// tun0 设备在 sing-box 启动早期就创建,但 gvisor 栈初始化仍需时间;
|
|
59
|
+
// 以 "sing-box started" 日志作为进程就绪标志,再等待栈初始化完成
|
|
60
|
+
await waitFor(200, 100, async () => {
|
|
61
|
+
try {
|
|
62
|
+
const log = await readFile(logPath, "utf8");
|
|
63
|
+
return log.includes("sing-box started");
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await sleep(4000);
|
|
69
|
+
await writeFile(readyFile, "");
|
|
70
|
+
|
|
71
|
+
await new Promise<void>((resolve) => singbox.once("exit", () => resolve()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await main();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(error);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdtemp, readFile, readlink, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { generateSingboxConfig } from "./singbox-config.js";
|
|
8
|
+
|
|
9
|
+
export interface NetworkStackOptions {
|
|
10
|
+
/** 允许直连的域名列表(session 启动时固定,reload 才会重新加载)。 */
|
|
11
|
+
readonly allowlist: readonly string[];
|
|
12
|
+
/** 真实 DNS 服务器列表,按顺序 fallback。 */
|
|
13
|
+
readonly dnsServers: readonly string[];
|
|
14
|
+
readonly singBoxPath: string;
|
|
15
|
+
readonly slirp4netnsPath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NetworkStackExecOptions {
|
|
19
|
+
readonly command: string;
|
|
20
|
+
readonly cwd: string;
|
|
21
|
+
readonly bwrapPath: string;
|
|
22
|
+
/** bwrap 的完整参数(不含 -- 后的 shell 与命令),由 core.ts 组装。 */
|
|
23
|
+
readonly bwrapArgs: readonly string[];
|
|
24
|
+
readonly shell: string;
|
|
25
|
+
readonly env: Readonly<Record<string, string>>;
|
|
26
|
+
readonly onData: (data: Buffer) => void;
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
readonly timeout?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
|
|
32
|
+
const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
|
|
33
|
+
// ip 通常位于 /usr/sbin 或 /sbin,进程默认 PATH 不含它们;node 则依赖宿主完整 PATH
|
|
34
|
+
const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
|
|
35
|
+
|
|
36
|
+
function killProcess(pid: number | undefined): void {
|
|
37
|
+
if (!pid) return;
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, "SIGTERM");
|
|
40
|
+
} catch {
|
|
41
|
+
// 已退出
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 读宿主机 /etc/resolv.conf 的全部 IPv4 nameserver,按声明顺序返回。 */
|
|
46
|
+
export async function resolveDnsServers(): Promise<string[]> {
|
|
47
|
+
const content = await readFile("/etc/resolv.conf", "utf8");
|
|
48
|
+
const nameservers = content
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => NAMESERVER_PATTERN.exec(line)?.[1])
|
|
51
|
+
.filter((value): value is string => value !== undefined);
|
|
52
|
+
// slirp4netns 出口仅 IPv4,IPv6 nameserver 无法出站,故只保留 IPv4
|
|
53
|
+
const ipv4 = nameservers.filter((value) => !value.includes(":"));
|
|
54
|
+
if (ipv4.length === 0) {
|
|
55
|
+
throw new Error("No IPv4 nameserver found in /etc/resolv.conf");
|
|
56
|
+
}
|
|
57
|
+
return ipv4;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 等待目标进程进入新的 user namespace(unshare 完成),返回后 slirp4netns 才能 setns。 */
|
|
61
|
+
async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
|
|
62
|
+
const self = await readlink("/proc/self/ns/user");
|
|
63
|
+
const deadline = Date.now() + timeoutMs;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
try {
|
|
66
|
+
if ((await readlink(`/proc/${pid}/ns/user`)) !== self) return;
|
|
67
|
+
} catch {
|
|
68
|
+
// /proc/<pid> 尚未就绪,重试
|
|
69
|
+
}
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
71
|
+
}
|
|
72
|
+
throw new Error("Timed out waiting for sandbox network namespace");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function waitForFile(path: string, timeoutMs = 15000): Promise<void> {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
try {
|
|
79
|
+
await access(path);
|
|
80
|
+
return;
|
|
81
|
+
} catch {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error("Timed out waiting for sandbox network stack to become ready");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function killChild(pid: number | undefined): void {
|
|
89
|
+
if (!pid) return;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(-pid, "SIGKILL");
|
|
92
|
+
} catch {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, "SIGKILL");
|
|
95
|
+
} catch {
|
|
96
|
+
// 已退出
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface NetworkStack {
|
|
102
|
+
exec(options: NetworkStackExecOptions): Promise<{ exitCode: number | null }>;
|
|
103
|
+
stop(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface NetworkStackState {
|
|
107
|
+
holderPid: number;
|
|
108
|
+
slirpPid: number | undefined;
|
|
109
|
+
directory: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程并清理目录。 */
|
|
113
|
+
const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
114
|
+
killProcess(state.slirpPid);
|
|
115
|
+
killProcess(state.holderPid);
|
|
116
|
+
void rm(state.directory, { recursive: true, force: true }).catch(() => false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 启动常驻的 per-session 网络栈:holder 进程持有 netns(内部跑 sing-box 的
|
|
121
|
+
* TUN + fakeip + deny-by-default),slirp4netns 提供 egress。返回前
|
|
122
|
+
* netns/sing-box/slirp4netns 均已就绪,命令通过 nsenter 进入该 netns 执行。
|
|
123
|
+
*/
|
|
124
|
+
export async function startNetworkStack(options: NetworkStackOptions): Promise<NetworkStack> {
|
|
125
|
+
const { allowlist, dnsServers, singBoxPath, slirp4netnsPath } = options;
|
|
126
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-netns-"));
|
|
127
|
+
const configPath = join(directory, "singbox.json");
|
|
128
|
+
const readyFile = join(directory, "ready");
|
|
129
|
+
await writeFile(configPath, generateSingboxConfig({ allowlist, dnsServers }));
|
|
130
|
+
|
|
131
|
+
const holder = spawn(
|
|
132
|
+
"unshare",
|
|
133
|
+
["-Urn", "--", "node", HOLDER_PATH, configPath, singBoxPath, readyFile],
|
|
134
|
+
{
|
|
135
|
+
env: {
|
|
136
|
+
HOME: process.env.HOME ?? "",
|
|
137
|
+
PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
|
|
138
|
+
},
|
|
139
|
+
stdio: "ignore",
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
if (holder.pid === undefined) {
|
|
143
|
+
throw new Error("Failed to start network namespace holder");
|
|
144
|
+
}
|
|
145
|
+
const holderPid = holder.pid;
|
|
146
|
+
await waitForNewUserns(holderPid);
|
|
147
|
+
|
|
148
|
+
const slirp = spawn(
|
|
149
|
+
slirp4netnsPath,
|
|
150
|
+
[
|
|
151
|
+
"-c",
|
|
152
|
+
`--userns-path=/proc/${holderPid}/ns/user`,
|
|
153
|
+
"--netns-type=pid",
|
|
154
|
+
String(holderPid),
|
|
155
|
+
"tap0",
|
|
156
|
+
],
|
|
157
|
+
{ stdio: "ignore" },
|
|
158
|
+
);
|
|
159
|
+
const slirpPid = slirp.pid;
|
|
160
|
+
await waitForFile(readyFile);
|
|
161
|
+
|
|
162
|
+
const state: NetworkStackState = { holderPid, slirpPid, directory };
|
|
163
|
+
const stack: NetworkStack = {
|
|
164
|
+
exec: async (execOptions: NetworkStackExecOptions) => {
|
|
165
|
+
const child = spawn(
|
|
166
|
+
"nsenter",
|
|
167
|
+
[
|
|
168
|
+
"-U",
|
|
169
|
+
"-n",
|
|
170
|
+
"--preserve-credentials",
|
|
171
|
+
"-t",
|
|
172
|
+
String(holderPid),
|
|
173
|
+
"--",
|
|
174
|
+
execOptions.bwrapPath,
|
|
175
|
+
...execOptions.bwrapArgs,
|
|
176
|
+
"--",
|
|
177
|
+
execOptions.shell,
|
|
178
|
+
"-lc",
|
|
179
|
+
execOptions.command,
|
|
180
|
+
],
|
|
181
|
+
{
|
|
182
|
+
cwd: execOptions.cwd,
|
|
183
|
+
detached: true,
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
env: execOptions.env,
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
190
|
+
let timedOut = false;
|
|
191
|
+
let settled = false;
|
|
192
|
+
const timeoutHandle = execOptions.timeout
|
|
193
|
+
? setTimeout(() => {
|
|
194
|
+
timedOut = true;
|
|
195
|
+
killChild(child.pid);
|
|
196
|
+
}, execOptions.timeout * 1000)
|
|
197
|
+
: undefined;
|
|
198
|
+
const onAbort = (): void => {
|
|
199
|
+
killChild(child.pid);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
child.stdout?.on("data", execOptions.onData);
|
|
203
|
+
child.stderr?.on("data", execOptions.onData);
|
|
204
|
+
execOptions.signal?.addEventListener("abort", onAbort, { once: true });
|
|
205
|
+
|
|
206
|
+
child.once("error", (error) => {
|
|
207
|
+
if (settled) return;
|
|
208
|
+
settled = true;
|
|
209
|
+
reject(error);
|
|
210
|
+
});
|
|
211
|
+
child.once("close", (exitCode) => {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
215
|
+
execOptions.signal?.removeEventListener("abort", onAbort);
|
|
216
|
+
if (execOptions.signal?.aborted) reject(new Error("aborted"));
|
|
217
|
+
else if (timedOut) reject(new Error(`timeout:${execOptions.timeout}`));
|
|
218
|
+
else resolve({ exitCode });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
stop: async () => {
|
|
223
|
+
killProcess(state.slirpPid);
|
|
224
|
+
killProcess(state.holderPid);
|
|
225
|
+
await rm(state.directory, { recursive: true, force: true }).catch(() => false);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
stackFinalizer.register(stack, state);
|
|
229
|
+
return stack;
|
|
230
|
+
}
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { commandPatternsFor } from "./approval-suggest.js";
|
|
|
28
28
|
import {
|
|
29
29
|
type BwrapMode,
|
|
30
30
|
createBwrapBashOperations,
|
|
31
|
+
createNetworkStack,
|
|
31
32
|
findBwrap,
|
|
32
33
|
getBwrapConfigPaths,
|
|
33
34
|
loadBwrapConfig,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
resolveHeadlessBwrap,
|
|
38
39
|
} from "./core.js";
|
|
39
40
|
import { dcgSuggestion } from "./dcg-scan.js";
|
|
41
|
+
import { type NetworkStack } from "./network-stack.js";
|
|
40
42
|
|
|
41
43
|
export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
|
|
42
44
|
|
|
@@ -230,6 +232,7 @@ function notifyMode(
|
|
|
230
232
|
"allow-all": "allow-all: sandbox off, network on",
|
|
231
233
|
"workspace-write": "workspace-write: sandbox on, network off",
|
|
232
234
|
"allow-net": "allow-net: sandbox on, network on, workspace writable",
|
|
235
|
+
"net-allowlist": "net-allowlist: sandbox on, network filtered by domain allowlist",
|
|
233
236
|
readonly: "readonly: sandbox on, network off, read-only fs",
|
|
234
237
|
};
|
|
235
238
|
ctx.ui.notify(labels[mode], "info");
|
|
@@ -239,6 +242,7 @@ export class BwrapRuntime {
|
|
|
239
242
|
private resolved: ResolvedBwrap | undefined;
|
|
240
243
|
private sandboxDisabled = false;
|
|
241
244
|
private bwrapUnavailable = false;
|
|
245
|
+
private networkStack: NetworkStack | undefined;
|
|
242
246
|
|
|
243
247
|
setup(pi: ExtensionAPI): void {
|
|
244
248
|
pi.registerFlag("no-bwrap", {
|
|
@@ -311,6 +315,8 @@ export class BwrapRuntime {
|
|
|
311
315
|
setMode(cwd: string, mode: BwrapMode): ResolvedBwrap {
|
|
312
316
|
this.resolved = resolveBwrap({ ...loadBwrapConfig(cwd), mode });
|
|
313
317
|
this.sandboxDisabled = false;
|
|
318
|
+
void this.networkStack?.stop();
|
|
319
|
+
this.networkStack = undefined;
|
|
314
320
|
return this.resolved;
|
|
315
321
|
}
|
|
316
322
|
|
|
@@ -318,6 +324,8 @@ export class BwrapRuntime {
|
|
|
318
324
|
this.resolved = undefined;
|
|
319
325
|
this.sandboxDisabled = false;
|
|
320
326
|
this.bwrapUnavailable = false;
|
|
327
|
+
void this.networkStack?.stop();
|
|
328
|
+
this.networkStack = undefined;
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
async execute(request: BwrapExecutionRequest): Promise<BwrapExecutionResult> {
|
|
@@ -356,7 +364,7 @@ export class BwrapRuntime {
|
|
|
356
364
|
const operations =
|
|
357
365
|
isWindows || needsApproval || !runtime.bwrapEnabled
|
|
358
366
|
? createLocalBashOperations()
|
|
359
|
-
: createBwrapBashOperations(runtime, workspace);
|
|
367
|
+
: createBwrapBashOperations(runtime, workspace, await this.getNetworkStack(runtime));
|
|
360
368
|
const output = new BashOutput(request.ctx.sessionManager.getSessionId());
|
|
361
369
|
const { onUpdate } = request;
|
|
362
370
|
|
|
@@ -446,6 +454,21 @@ export class BwrapRuntime {
|
|
|
446
454
|
return this.resolved;
|
|
447
455
|
}
|
|
448
456
|
|
|
457
|
+
/** net-allowlist 模式的常驻网络栈:惰性创建,session 期间复用(allowlist 固定)。 */
|
|
458
|
+
private async getNetworkStack(runtime: ResolvedBwrap): Promise<NetworkStack | undefined> {
|
|
459
|
+
if (runtime.mode !== "net-allowlist" || runtime.networkAllowlist.length === 0) {
|
|
460
|
+
if (this.networkStack) {
|
|
461
|
+
void this.networkStack.stop();
|
|
462
|
+
this.networkStack = undefined;
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
if (!this.networkStack) {
|
|
467
|
+
this.networkStack = await createNetworkStack(runtime);
|
|
468
|
+
}
|
|
469
|
+
return this.networkStack;
|
|
470
|
+
}
|
|
471
|
+
|
|
449
472
|
private async approveFullAccess(
|
|
450
473
|
ctx: ExtensionContext,
|
|
451
474
|
command: string,
|
|
@@ -646,12 +669,24 @@ export class BwrapRuntime {
|
|
|
646
669
|
description: "Sandbox on, network on, workspace writable",
|
|
647
670
|
flags: Type.Object({}),
|
|
648
671
|
},
|
|
672
|
+
"bwrap-net-allowlist": {
|
|
673
|
+
name: "bwrap-net-allowlist",
|
|
674
|
+
usage: "",
|
|
675
|
+
description: "Sandbox on, network filtered by domain allowlist, workspace writable",
|
|
676
|
+
flags: Type.Object({}),
|
|
677
|
+
},
|
|
649
678
|
"bwrap-readonly": {
|
|
650
679
|
name: "bwrap-readonly",
|
|
651
680
|
usage: "",
|
|
652
681
|
description: "Sandbox on, network off, no writes",
|
|
653
682
|
flags: Type.Object({}),
|
|
654
683
|
},
|
|
684
|
+
"bwrap-reload": {
|
|
685
|
+
name: "bwrap-reload",
|
|
686
|
+
usage: "",
|
|
687
|
+
description: "Reload bwrap config and restart the network stack",
|
|
688
|
+
flags: Type.Object({}),
|
|
689
|
+
},
|
|
655
690
|
} as const satisfies Record<string, CommandSpec<TObject>>;
|
|
656
691
|
|
|
657
692
|
pi.registerCommand("bwrap", {
|
|
@@ -685,6 +720,7 @@ export class BwrapRuntime {
|
|
|
685
720
|
["bwrap-allow-all", "allow-all"],
|
|
686
721
|
["bwrap-workspace-write", "workspace-write"],
|
|
687
722
|
["bwrap-allow-net", "allow-net"],
|
|
723
|
+
["bwrap-net-allowlist", "net-allowlist"],
|
|
688
724
|
["bwrap-readonly", "readonly"],
|
|
689
725
|
] as const) {
|
|
690
726
|
pi.registerCommand(name, {
|
|
@@ -695,6 +731,24 @@ export class BwrapRuntime {
|
|
|
695
731
|
),
|
|
696
732
|
});
|
|
697
733
|
}
|
|
734
|
+
|
|
735
|
+
pi.registerCommand("bwrap-reload", {
|
|
736
|
+
description: specs["bwrap-reload"].description,
|
|
737
|
+
handler: (args, ctx) =>
|
|
738
|
+
this.runCommand(pi, specs["bwrap-reload"], args, ctx, (commandCtx) => {
|
|
739
|
+
this.reload(commandCtx);
|
|
740
|
+
}),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private reload(ctx: ExtensionCommandContext): void {
|
|
745
|
+
this.resolved = undefined;
|
|
746
|
+
this.bwrapUnavailable = false;
|
|
747
|
+
void this.networkStack?.stop();
|
|
748
|
+
this.networkStack = undefined;
|
|
749
|
+
const runtime = this.resolve(ctx);
|
|
750
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${runtime.mode}`));
|
|
751
|
+
ctx.ui.notify(`bwrap config reloaded (mode: ${runtime.mode})`, "info");
|
|
698
752
|
}
|
|
699
753
|
|
|
700
754
|
private switchMode(pi: ExtensionAPI, mode: BwrapMode, ctx: ExtensionCommandContext): void {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const FAKEIP_RANGE = "198.18.0.0/15";
|
|
2
|
+
const TUN_ADDRESS = "198.18.0.1/16";
|
|
3
|
+
const EGRESS_INTERFACE = "tap0";
|
|
4
|
+
|
|
5
|
+
export interface SingboxConfigOptions {
|
|
6
|
+
/** 允许直连的域名列表(空列表 = 默认拒绝一切出网)。 */
|
|
7
|
+
readonly allowlist: readonly string[];
|
|
8
|
+
/** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
|
|
9
|
+
readonly dnsServers: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 生成 sing-box 1.13 配置:TUN + fakeip + deny-by-default allowlist。
|
|
14
|
+
*
|
|
15
|
+
* - 出站绑定到 slirp4netns 的 tap0,避免 auto_route 把 sing-box 自身出站
|
|
16
|
+
* 流量(尤其 DNS 解析)重新路由回 TUN 形成环。
|
|
17
|
+
* - 真实解析不设 domain_resolver(它只接受单个 server 且绕过规则),而是
|
|
18
|
+
* 用 DNS rules 的顺序 fallback 链:前 N-1 个 nameserver 带 ip_accept_any
|
|
19
|
+
* 过滤(响应不合格时落到下一条),最后一个作为 dns.final 兜底。
|
|
20
|
+
* 该用法在 1.13 已标记 deprecated,运行需设置
|
|
21
|
+
* ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER=true。
|
|
22
|
+
*/
|
|
23
|
+
export function generateSingboxConfig(options: SingboxConfigOptions): string {
|
|
24
|
+
const { allowlist, dnsServers } = options;
|
|
25
|
+
const lastServer = dnsServers.at(-1);
|
|
26
|
+
if (!lastServer) {
|
|
27
|
+
throw new Error("At least one DNS server is required");
|
|
28
|
+
}
|
|
29
|
+
const config = {
|
|
30
|
+
log: { level: "info", timestamp: false },
|
|
31
|
+
dns: {
|
|
32
|
+
servers: [
|
|
33
|
+
...dnsServers.map((server, index) => ({
|
|
34
|
+
type: "udp",
|
|
35
|
+
tag: `remote-${index}`,
|
|
36
|
+
server,
|
|
37
|
+
server_port: 53,
|
|
38
|
+
})),
|
|
39
|
+
{ type: "fakeip", tag: "fakeip", inet4_range: FAKEIP_RANGE },
|
|
40
|
+
],
|
|
41
|
+
rules: [
|
|
42
|
+
{ query_type: ["A", "AAAA"], server: "fakeip" },
|
|
43
|
+
...dnsServers.slice(0, -1).map((_, index) => ({
|
|
44
|
+
server: `remote-${index}`,
|
|
45
|
+
ip_accept_any: true,
|
|
46
|
+
})),
|
|
47
|
+
],
|
|
48
|
+
final: `remote-${dnsServers.length - 1}`,
|
|
49
|
+
strategy: "prefer_ipv4",
|
|
50
|
+
independent_cache: true,
|
|
51
|
+
},
|
|
52
|
+
inbounds: [
|
|
53
|
+
{
|
|
54
|
+
type: "tun",
|
|
55
|
+
tag: "tun-in",
|
|
56
|
+
stack: "gvisor",
|
|
57
|
+
address: [TUN_ADDRESS],
|
|
58
|
+
auto_route: true,
|
|
59
|
+
strict_route: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
outbounds: [
|
|
63
|
+
{ type: "direct", tag: "direct" },
|
|
64
|
+
{ type: "block", tag: "block" },
|
|
65
|
+
],
|
|
66
|
+
route: {
|
|
67
|
+
default_interface: EGRESS_INTERFACE,
|
|
68
|
+
final: "block",
|
|
69
|
+
rules: [
|
|
70
|
+
{ action: "sniff" },
|
|
71
|
+
{ protocol: "dns", action: "hijack-dns" },
|
|
72
|
+
...(allowlist.length > 0 ? [{ domain: [...allowlist], outbound: "direct" }] : []),
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox";
|
|
2
|
+
import Value, { ParseError } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 用 typebox schema 解析 value。成功返回解析结果;失败抛带字段路径的 Error
|
|
6
|
+
* (Value.Parse 的 ParseError 错误详情在 cause.errors,默认 message 只有
|
|
7
|
+
* "Parse",这里拼成可读信息,供调用方包装上下文如配置文件路径)。
|
|
8
|
+
*/
|
|
9
|
+
export function parseWithSchema<T extends TSchema>(schema: T, value: unknown): Static<T> {
|
|
10
|
+
try {
|
|
11
|
+
return Value.Parse(schema, value);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error instanceof ParseError) {
|
|
14
|
+
const details = error.cause.errors
|
|
15
|
+
.map((e) => `${e.instancePath || "(root)"}: ${e.message}`)
|
|
16
|
+
.join("; ");
|
|
17
|
+
throw new Error(details, { cause: error });
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|