@trim21/personal-pi-extensions 0.0.431 → 0.0.432
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 +10 -10
- package/src/bwrap/holder.js +6 -6
- package/src/bwrap/holder.ts +8 -8
- package/src/bwrap/mihomo-config.ts +183 -0
- package/src/bwrap/network-stack.ts +16 -15
- package/src/bwrap/singbox-config.ts +0 -164
package/package.json
CHANGED
package/src/bwrap/core.ts
CHANGED
|
@@ -40,7 +40,7 @@ const bwrapConfigProperties = {
|
|
|
40
40
|
networkAllowlist: Type.Array(
|
|
41
41
|
Type.String({ description: "允许直连的域名 / IP / CIDR,可带 :port" }),
|
|
42
42
|
),
|
|
43
|
-
|
|
43
|
+
mihomoPath: Type.Optional(Type.String()),
|
|
44
44
|
slirp4netnsPath: Type.Optional(Type.String()),
|
|
45
45
|
approvalRules: Type.Optional(
|
|
46
46
|
Type.Array(
|
|
@@ -78,9 +78,9 @@ export interface ResolvedBwrap {
|
|
|
78
78
|
/** 沙箱内隐藏的路径:以 / 结尾为目录(挂空 tmpfs),否则为文件(--ro-bind-try /dev/null)。 */
|
|
79
79
|
denyPaths: string[];
|
|
80
80
|
extraArgs: string[];
|
|
81
|
-
/** 允许直连的域名 / IP / IP:port 白名单(非空 = 启用
|
|
81
|
+
/** 允许直连的域名 / IP / IP:port 白名单(非空 = 启用 mihomo 网络过滤)。 */
|
|
82
82
|
networkAllowlist: string[];
|
|
83
|
-
|
|
83
|
+
mihomoPath?: string;
|
|
84
84
|
slirp4netnsPath?: string;
|
|
85
85
|
/** 全权限执行的自动审批规则(allow/deny 命令模式)。 */
|
|
86
86
|
approvalRules: ApprovalRule[];
|
|
@@ -104,7 +104,7 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
104
104
|
denyPaths: config.denyPaths ?? [],
|
|
105
105
|
extraArgs: config.extraArgs ?? [],
|
|
106
106
|
networkAllowlist: config.networkAllowlist ?? [],
|
|
107
|
-
|
|
107
|
+
mihomoPath: config.mihomoPath,
|
|
108
108
|
slirp4netnsPath: config.slirp4netnsPath,
|
|
109
109
|
approvalRules: config.approvalRules ?? [],
|
|
110
110
|
};
|
|
@@ -147,7 +147,7 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
|
|
|
147
147
|
denyPaths: overrides.denyPaths ?? base.denyPaths,
|
|
148
148
|
extraArgs: overrides.extraArgs ?? base.extraArgs,
|
|
149
149
|
networkAllowlist: overrides.networkAllowlist ?? base.networkAllowlist,
|
|
150
|
-
|
|
150
|
+
mihomoPath: overrides.mihomoPath ?? base.mihomoPath,
|
|
151
151
|
slirp4netnsPath: overrides.slirp4netnsPath ?? base.slirp4netnsPath,
|
|
152
152
|
approvalRules: [...(base.approvalRules ?? []), ...(overrides.approvalRules ?? [])],
|
|
153
153
|
};
|
|
@@ -234,16 +234,16 @@ function findCommandInPath(name: string, hint: string): string {
|
|
|
234
234
|
throw new Error(hint);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
export function
|
|
237
|
+
export function findMihomo(override?: string): string {
|
|
238
238
|
if (override) {
|
|
239
239
|
if (!existsSync(override)) {
|
|
240
|
-
throw new Error(`
|
|
240
|
+
throw new Error(`mihomo not found at configured path: ${override}`);
|
|
241
241
|
}
|
|
242
242
|
return override;
|
|
243
243
|
}
|
|
244
244
|
return findCommandInPath(
|
|
245
|
-
"
|
|
246
|
-
"
|
|
245
|
+
"mihomo",
|
|
246
|
+
"mihomo not found in PATH. Install it from https://github.com/MetaCubeX/mihomo",
|
|
247
247
|
);
|
|
248
248
|
}
|
|
249
249
|
|
|
@@ -378,7 +378,7 @@ export async function createNetworkStack(
|
|
|
378
378
|
return startNetworkStack({
|
|
379
379
|
allowlist: resolved.networkAllowlist,
|
|
380
380
|
dnsServers: await resolveDnsServers(),
|
|
381
|
-
|
|
381
|
+
mihomoPath: findMihomo(resolved.mihomoPath),
|
|
382
382
|
slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
|
|
383
383
|
});
|
|
384
384
|
}
|
package/src/bwrap/holder.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
4
|
const args = process.argv.slice(2);
|
|
4
5
|
const configPath = args[0];
|
|
5
|
-
const
|
|
6
|
-
if (!configPath || !
|
|
6
|
+
const mihomoPath = args[1];
|
|
7
|
+
if (!configPath || !mihomoPath) {
|
|
7
8
|
process.exit(2);
|
|
8
9
|
}
|
|
9
10
|
function tap0Exists() {
|
|
@@ -22,12 +23,11 @@ async function waitFor(attempts, delayMs, predicate) {
|
|
|
22
23
|
}
|
|
23
24
|
async function main() {
|
|
24
25
|
await waitFor(100, 100, tap0Exists);
|
|
25
|
-
const
|
|
26
|
-
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
26
|
+
const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
|
|
27
27
|
stdio: ["ignore", "inherit", "inherit"]
|
|
28
28
|
});
|
|
29
29
|
const stop = () => {
|
|
30
|
-
|
|
30
|
+
mihomo.kill("SIGTERM");
|
|
31
31
|
};
|
|
32
32
|
process.on("SIGTERM", () => {
|
|
33
33
|
stop();
|
|
@@ -37,7 +37,7 @@ async function main() {
|
|
|
37
37
|
stop();
|
|
38
38
|
process.exit(0);
|
|
39
39
|
});
|
|
40
|
-
await new Promise((resolve) =>
|
|
40
|
+
await new Promise((resolve) => mihomo.once("exit", () => resolve()));
|
|
41
41
|
}
|
|
42
42
|
try {
|
|
43
43
|
await main();
|
package/src/bwrap/holder.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
5
|
|
|
5
|
-
// 用法:unshare -Urn node holder.ts <config> <
|
|
6
|
+
// 用法:unshare -Urn node holder.ts <config> <mihomo>
|
|
6
7
|
const args = process.argv.slice(2);
|
|
7
8
|
const configPath = args[0];
|
|
8
|
-
const
|
|
9
|
-
if (!configPath || !
|
|
9
|
+
const mihomoPath = args[1];
|
|
10
|
+
if (!configPath || !mihomoPath) {
|
|
10
11
|
process.exit(2);
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -34,14 +35,13 @@ async function main(): Promise<void> {
|
|
|
34
35
|
// slirp4netns 由宿主侧 spawn,这里等它建好 tap0
|
|
35
36
|
await waitFor(100, 100, tap0Exists);
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
38
|
+
// mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪
|
|
39
|
+
const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
|
|
40
40
|
stdio: ["ignore", "inherit", "inherit"],
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const stop = (): void => {
|
|
44
|
-
|
|
44
|
+
mihomo.kill("SIGTERM");
|
|
45
45
|
};
|
|
46
46
|
process.on("SIGTERM", () => {
|
|
47
47
|
stop();
|
|
@@ -52,7 +52,7 @@ async function main(): Promise<void> {
|
|
|
52
52
|
process.exit(0);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
await new Promise<void>((resolve) =>
|
|
55
|
+
await new Promise<void>((resolve) => mihomo.once("exit", () => resolve()));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
try {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const FAKEIP_RANGE = "198.18.0.1/16";
|
|
2
|
+
const TUN_MTU = 1500;
|
|
3
|
+
|
|
4
|
+
export interface MihomoConfigOptions {
|
|
5
|
+
/** 允许直连的条目列表(域名 / IP / CIDR,可带 :port;空列表 = 默认拒绝一切出网)。 */
|
|
6
|
+
readonly allowlist: readonly string[];
|
|
7
|
+
/** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
|
|
8
|
+
readonly dnsServers: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AllowlistEntry {
|
|
12
|
+
readonly host: string;
|
|
13
|
+
readonly port?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** mihomo 配置以 JSON 序列化输出(JSON 是 YAML 子集,-f 加载无差别)。 */
|
|
17
|
+
export interface MihomoConfig {
|
|
18
|
+
"mixed-port": 0;
|
|
19
|
+
mode: "rule";
|
|
20
|
+
"log-level": "info";
|
|
21
|
+
ipv6: false;
|
|
22
|
+
dns: {
|
|
23
|
+
enable: true;
|
|
24
|
+
ipv6: false;
|
|
25
|
+
"enhanced-mode": "fake-ip";
|
|
26
|
+
"fake-ip-range": string;
|
|
27
|
+
nameserver: string[];
|
|
28
|
+
"default-nameserver": string[];
|
|
29
|
+
"fake-ip-filter"?: string[];
|
|
30
|
+
};
|
|
31
|
+
tun: {
|
|
32
|
+
enable: true;
|
|
33
|
+
stack: "mixed";
|
|
34
|
+
mtu: number;
|
|
35
|
+
"auto-route": true;
|
|
36
|
+
"strict-route": true;
|
|
37
|
+
"auto-detect-interface": true;
|
|
38
|
+
"dns-hijack": string[];
|
|
39
|
+
};
|
|
40
|
+
rules: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/;
|
|
44
|
+
/** 合法 DNS 主机名(标签 1-63 字符,字母数字加连字符,不得以连字符开头/结尾)。 */
|
|
45
|
+
const DOMAIN_PATTERN =
|
|
46
|
+
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
|
47
|
+
/** IP/CIDR 的字符集校验(IPv4/IPv6),防止非法字符进入规则。 */
|
|
48
|
+
const IP_CHARS_PATTERN = /^[0-9a-fA-F:.]+(?:\/\d{1,3})?$/;
|
|
49
|
+
|
|
50
|
+
function isIp(host: string): boolean {
|
|
51
|
+
return IPV4_PATTERN.test(host) || host.includes(":");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 单 IP 补掩码为 CIDR(IPv4 /32、IPv6 /128),带掩码原样返回。 */
|
|
55
|
+
function toCidr(host: string): string {
|
|
56
|
+
if (host.includes("/")) return host;
|
|
57
|
+
return host.includes(":") ? `${host}/128` : `${host}/32`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parsePort(value: string): number {
|
|
61
|
+
const port = Number(value);
|
|
62
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
63
|
+
throw new Error(`Invalid port "${value}"`);
|
|
64
|
+
}
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port;IPv6 必须用 [] 包裹
|
|
70
|
+
* (如 `[::1]:80`),裸 IPv6 会报错提示补方括号。
|
|
71
|
+
*/
|
|
72
|
+
function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
73
|
+
let host: string;
|
|
74
|
+
let port: number | undefined;
|
|
75
|
+
if (entry.startsWith("[")) {
|
|
76
|
+
const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
|
|
77
|
+
if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
78
|
+
host = match[1];
|
|
79
|
+
port = match[2] === undefined ? undefined : parsePort(match[2]);
|
|
80
|
+
} else {
|
|
81
|
+
const colon = entry.lastIndexOf(":");
|
|
82
|
+
if (colon === -1) {
|
|
83
|
+
if (entry.length === 0) throw new Error(`Invalid allowlist entry ""`);
|
|
84
|
+
host = entry;
|
|
85
|
+
} else {
|
|
86
|
+
const portPart = entry.slice(colon + 1);
|
|
87
|
+
if (!/^\d+$/.test(portPart)) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
88
|
+
host = entry.slice(0, colon);
|
|
89
|
+
if (host.length === 0) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
90
|
+
if (host.includes(":")) {
|
|
91
|
+
throw new Error(`IPv6 addresses must be wrapped in brackets, e.g. "[${host}]:${portPart}"`);
|
|
92
|
+
}
|
|
93
|
+
port = parsePort(portPart);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (isIp(host)) {
|
|
97
|
+
if (!IP_CHARS_PATTERN.test(host)) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
98
|
+
} else if (!DOMAIN_PATTERN.test(host)) {
|
|
99
|
+
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
100
|
+
}
|
|
101
|
+
return { host, port };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** IP 条目的 mihomo 规则(IPv6 用 IP-CIDR6;no-resolve 跳过反向解析)。 */
|
|
105
|
+
function ipRule(host: string): string {
|
|
106
|
+
const cidr = toCidr(host);
|
|
107
|
+
const kind = cidr.includes(":") ? "IP-CIDR6" : "IP-CIDR";
|
|
108
|
+
return `${kind},${cidr},DIRECT,no-resolve`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface BuiltRules {
|
|
112
|
+
rules: string[];
|
|
113
|
+
fakeIpFilter: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* allowlist 条目 → mihomo 规则:
|
|
118
|
+
* - 域名(含 :port 条目里的域名)进 fake-ip-filter,真实解析避免 DIRECT 出站
|
|
119
|
+
* 拿到 fakeip 再进 TUN 形成环(loopback detector 会拒绝);
|
|
120
|
+
* - 无端口条目直接匹配;带端口条目用 AND 组合(域名/IP + DST-PORT)精确放行;
|
|
121
|
+
* - 最后以 MATCH,REJECT 兜底实现 deny-by-default。
|
|
122
|
+
*/
|
|
123
|
+
function buildRules(allowlist: readonly string[]): BuiltRules {
|
|
124
|
+
const rules: string[] = [];
|
|
125
|
+
const fakeIpFilter: string[] = [];
|
|
126
|
+
for (const entry of allowlist) {
|
|
127
|
+
const { host, port } = parseAllowlistEntry(entry);
|
|
128
|
+
if (!isIp(host)) {
|
|
129
|
+
fakeIpFilter.push(`+.${host}`);
|
|
130
|
+
if (port === undefined) {
|
|
131
|
+
rules.push(`DOMAIN-SUFFIX,${host},DIRECT`);
|
|
132
|
+
} else {
|
|
133
|
+
rules.push(`AND,(DOMAIN-SUFFIX,${host},DIRECT),(DST-PORT,${port},DIRECT),DIRECT`);
|
|
134
|
+
}
|
|
135
|
+
} else if (port === undefined) {
|
|
136
|
+
rules.push(ipRule(host));
|
|
137
|
+
} else {
|
|
138
|
+
rules.push(`AND,(${ipRule(host)}),(DST-PORT,${port},DIRECT),DIRECT`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
rules.push("MATCH,REJECT");
|
|
142
|
+
return { rules, fakeIpFilter };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 生成 mihomo(Clash Meta)配置对象:TUN + fakeip + deny-by-default allowlist。
|
|
147
|
+
*
|
|
148
|
+
* - auto-detect-interface 让 mihomo 出站绑定 slirp4netns 的 tap0,否则它自己的
|
|
149
|
+
* DNS 查询会被 auto_route 送回 TUN 形成环;
|
|
150
|
+
* - allowlist 域名走 fake-ip-filter 真实解析(见 buildRules 注释)。
|
|
151
|
+
*/
|
|
152
|
+
export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig {
|
|
153
|
+
const { allowlist, dnsServers } = options;
|
|
154
|
+
if (dnsServers.length === 0) {
|
|
155
|
+
throw new Error("At least one DNS server is required");
|
|
156
|
+
}
|
|
157
|
+
const { rules, fakeIpFilter } = buildRules(allowlist);
|
|
158
|
+
return {
|
|
159
|
+
"mixed-port": 0,
|
|
160
|
+
mode: "rule",
|
|
161
|
+
"log-level": "info",
|
|
162
|
+
ipv6: false,
|
|
163
|
+
dns: {
|
|
164
|
+
enable: true,
|
|
165
|
+
ipv6: false,
|
|
166
|
+
"enhanced-mode": "fake-ip",
|
|
167
|
+
"fake-ip-range": FAKEIP_RANGE,
|
|
168
|
+
nameserver: [...dnsServers],
|
|
169
|
+
"default-nameserver": [...dnsServers],
|
|
170
|
+
...(fakeIpFilter.length > 0 && { "fake-ip-filter": fakeIpFilter }),
|
|
171
|
+
},
|
|
172
|
+
tun: {
|
|
173
|
+
enable: true,
|
|
174
|
+
stack: "mixed",
|
|
175
|
+
mtu: TUN_MTU,
|
|
176
|
+
"auto-route": true,
|
|
177
|
+
"strict-route": true,
|
|
178
|
+
"auto-detect-interface": true,
|
|
179
|
+
"dns-hijack": ["any:53"],
|
|
180
|
+
},
|
|
181
|
+
rules,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -4,14 +4,14 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { generateMihomoConfig } from "./mihomo-config.js";
|
|
8
8
|
|
|
9
9
|
export interface NetworkStackOptions {
|
|
10
10
|
/** 允许直连的域名 / IP:port 列表(每次命令从配置重新读取)。 */
|
|
11
11
|
readonly allowlist: readonly string[];
|
|
12
12
|
/** 真实 DNS 服务器列表,按顺序 fallback。 */
|
|
13
13
|
readonly dnsServers: readonly string[];
|
|
14
|
-
readonly
|
|
14
|
+
readonly mihomoPath: string;
|
|
15
15
|
readonly slirp4netnsPath: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -72,18 +72,18 @@ async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
|
|
|
72
72
|
throw new Error("Timed out waiting for sandbox network namespace");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** 监听 holder 的 stdout/stderr(
|
|
76
|
-
function
|
|
75
|
+
/** 监听 holder 的 stdout/stderr(mihomo 日志透传),以 "Tun adapter listening" 作为就绪标志。 */
|
|
76
|
+
function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<void> {
|
|
77
77
|
return new Promise((resolve, reject) => {
|
|
78
78
|
let settled = false;
|
|
79
79
|
const timer = setTimeout(() => {
|
|
80
80
|
if (settled) return;
|
|
81
81
|
settled = true;
|
|
82
|
-
reject(new Error("Timed out waiting for
|
|
82
|
+
reject(new Error("Timed out waiting for mihomo to start"));
|
|
83
83
|
}, timeoutMs);
|
|
84
84
|
const onData = (data: Buffer): void => {
|
|
85
85
|
if (settled) return;
|
|
86
|
-
if (data.toString().includes("
|
|
86
|
+
if (data.toString().includes("Tun adapter listening")) {
|
|
87
87
|
settled = true;
|
|
88
88
|
clearTimeout(timer);
|
|
89
89
|
resolve();
|
|
@@ -95,7 +95,7 @@ function waitForSingboxStarted(holder: ChildProcess, timeoutMs = 20000): Promise
|
|
|
95
95
|
if (settled) return;
|
|
96
96
|
settled = true;
|
|
97
97
|
clearTimeout(timer);
|
|
98
|
-
reject(new Error(`Sandbox holder exited before
|
|
98
|
+
reject(new Error(`Sandbox holder exited before mihomo started (code ${code})`));
|
|
99
99
|
});
|
|
100
100
|
});
|
|
101
101
|
}
|
|
@@ -132,22 +132,23 @@ const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* 启动网络栈:holder 进程持有 netns(内部跑
|
|
135
|
+
* 启动网络栈:holder 进程持有 netns(内部跑 mihomo 的
|
|
136
136
|
* TUN + fakeip + deny-by-default),slirp4netns 提供 egress。返回前
|
|
137
|
-
* netns/
|
|
137
|
+
* netns/mihomo/slirp4netns 均已就绪,命令通过 nsenter 进入该 netns 执行。
|
|
138
138
|
*/
|
|
139
139
|
export async function startNetworkStack(options: NetworkStackOptions): Promise<NetworkStack> {
|
|
140
|
-
const { allowlist, dnsServers,
|
|
140
|
+
const { allowlist, dnsServers, mihomoPath, slirp4netnsPath } = options;
|
|
141
141
|
const directory = await mkdtemp(join(tmpdir(), "pi-netns-"));
|
|
142
|
-
const configPath = join(directory, "
|
|
143
|
-
|
|
142
|
+
const configPath = join(directory, "mihomo.json");
|
|
143
|
+
const config = generateMihomoConfig({ allowlist, dnsServers });
|
|
144
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
144
145
|
|
|
145
|
-
const holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath,
|
|
146
|
+
const holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath, mihomoPath], {
|
|
146
147
|
env: {
|
|
147
148
|
HOME: process.env.HOME ?? "",
|
|
148
149
|
PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
|
|
149
150
|
},
|
|
150
|
-
//
|
|
151
|
+
// mihomo 日志经 holder 透传到这里的 stdout/stderr,用于判定就绪
|
|
151
152
|
stdio: ["ignore", "pipe", "pipe"],
|
|
152
153
|
});
|
|
153
154
|
if (holder.pid === undefined) {
|
|
@@ -168,7 +169,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
168
169
|
{ stdio: "ignore" },
|
|
169
170
|
);
|
|
170
171
|
const slirpPid = slirp.pid;
|
|
171
|
-
await
|
|
172
|
+
await waitForMihomoStarted(holder);
|
|
172
173
|
|
|
173
174
|
const state: NetworkStackState = { holderPid, slirpPid, directory };
|
|
174
175
|
const stack: NetworkStack = {
|
|
@@ -1,164 +0,0 @@
|
|
|
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
|
-
/** 允许直连的条目列表(域名 / IP / CIDR,可带 :port;空列表 = 默认拒绝一切出网)。 */
|
|
7
|
-
readonly allowlist: readonly string[];
|
|
8
|
-
/** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
|
|
9
|
-
readonly dnsServers: readonly string[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface AllowlistEntry {
|
|
13
|
-
readonly host: string;
|
|
14
|
-
readonly port?: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface AllowlistRule {
|
|
18
|
-
readonly domain?: readonly string[];
|
|
19
|
-
readonly ip_cidr?: readonly string[];
|
|
20
|
-
readonly port?: readonly number[];
|
|
21
|
-
readonly outbound: "direct";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/;
|
|
25
|
-
|
|
26
|
-
function isIp(host: string): boolean {
|
|
27
|
-
return IPV4_PATTERN.test(host) || host.includes(":");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 单 IP 补掩码为 CIDR(IPv4 /32、IPv6 /128),带掩码原样返回。 */
|
|
31
|
-
function toCidr(host: string): string {
|
|
32
|
-
if (host.includes("/")) return host;
|
|
33
|
-
return host.includes(":") ? `${host}/128` : `${host}/32`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function parsePort(value: string): number {
|
|
37
|
-
const port = Number(value);
|
|
38
|
-
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
39
|
-
throw new Error(`Invalid port "${value}"`);
|
|
40
|
-
}
|
|
41
|
-
return port;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port;IPv6 必须用 [] 包裹
|
|
46
|
-
* (如 `[::1]:80`),裸 IPv6 会报错提示补方括号。
|
|
47
|
-
*/
|
|
48
|
-
function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
49
|
-
if (entry.startsWith("[")) {
|
|
50
|
-
const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
|
|
51
|
-
if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
52
|
-
return { host: match[1], port: match[2] === undefined ? undefined : parsePort(match[2]) };
|
|
53
|
-
}
|
|
54
|
-
const colon = entry.lastIndexOf(":");
|
|
55
|
-
if (colon === -1) {
|
|
56
|
-
if (entry.length === 0) throw new Error(`Invalid allowlist entry ""`);
|
|
57
|
-
return { host: entry };
|
|
58
|
-
}
|
|
59
|
-
const portPart = entry.slice(colon + 1);
|
|
60
|
-
if (!/^\d+$/.test(portPart)) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
61
|
-
const host = entry.slice(0, colon);
|
|
62
|
-
if (host.length === 0) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
63
|
-
if (host.includes(":")) {
|
|
64
|
-
throw new Error(`IPv6 addresses must be wrapped in brackets, e.g. "[${host}]:${portPart}"`);
|
|
65
|
-
}
|
|
66
|
-
return { host, port: parsePort(portPart) };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function buildAllowlistRules(allowlist: readonly string[]): AllowlistRule[] {
|
|
70
|
-
const noPortDomains: string[] = [];
|
|
71
|
-
const noPortIps: string[] = [];
|
|
72
|
-
const byPort = new Map<number, { domains: string[]; ips: string[] }>();
|
|
73
|
-
for (const entry of allowlist) {
|
|
74
|
-
const { host, port } = parseAllowlistEntry(entry);
|
|
75
|
-
if (port === undefined) {
|
|
76
|
-
(isIp(host) ? noPortIps : noPortDomains).push(host);
|
|
77
|
-
} else {
|
|
78
|
-
const bucket = byPort.get(port) ?? { domains: [], ips: [] };
|
|
79
|
-
(isIp(host) ? bucket.ips : bucket.domains).push(host);
|
|
80
|
-
byPort.set(port, bucket);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const rules: AllowlistRule[] = [];
|
|
84
|
-
if (noPortDomains.length > 0) rules.push({ domain: noPortDomains, outbound: "direct" });
|
|
85
|
-
if (noPortIps.length > 0) {
|
|
86
|
-
rules.push({ ip_cidr: noPortIps.map((host) => toCidr(host)), outbound: "direct" });
|
|
87
|
-
}
|
|
88
|
-
for (const [port, bucket] of byPort) {
|
|
89
|
-
rules.push({
|
|
90
|
-
outbound: "direct",
|
|
91
|
-
port: [port],
|
|
92
|
-
...(bucket.domains.length > 0 && { domain: bucket.domains }),
|
|
93
|
-
...(bucket.ips.length > 0 && { ip_cidr: bucket.ips.map((host) => toCidr(host)) }),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
return rules;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* 生成 sing-box 1.13 配置:TUN + fakeip + deny-by-default allowlist。
|
|
101
|
-
*
|
|
102
|
-
* - 出站绑定到 slirp4netns 的 tap0,避免 auto_route 把 sing-box 自身出站
|
|
103
|
-
* 流量(尤其 DNS 解析)重新路由回 TUN 形成环。
|
|
104
|
-
* - 真实解析不设 domain_resolver(它只接受单个 server 且绕过规则),而是
|
|
105
|
-
* 用 DNS rules 的顺序 fallback 链:前 N-1 个 nameserver 带 ip_accept_any
|
|
106
|
-
* 过滤(响应不合格时落到下一条),最后一个作为 dns.final 兜底。
|
|
107
|
-
* 该用法在 1.13 已标记 deprecated,运行需设置
|
|
108
|
-
* ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER=true。
|
|
109
|
-
*/
|
|
110
|
-
export function generateSingboxConfig(options: SingboxConfigOptions): string {
|
|
111
|
-
const { allowlist, dnsServers } = options;
|
|
112
|
-
const lastServer = dnsServers.at(-1);
|
|
113
|
-
if (!lastServer) {
|
|
114
|
-
throw new Error("At least one DNS server is required");
|
|
115
|
-
}
|
|
116
|
-
const config = {
|
|
117
|
-
log: { level: "info", timestamp: false },
|
|
118
|
-
dns: {
|
|
119
|
-
servers: [
|
|
120
|
-
...dnsServers.map((server, index) => ({
|
|
121
|
-
type: "udp",
|
|
122
|
-
tag: `remote-${index}`,
|
|
123
|
-
server,
|
|
124
|
-
server_port: 53,
|
|
125
|
-
})),
|
|
126
|
-
{ type: "fakeip", tag: "fakeip", inet4_range: FAKEIP_RANGE },
|
|
127
|
-
],
|
|
128
|
-
rules: [
|
|
129
|
-
{ query_type: ["A", "AAAA"], server: "fakeip" },
|
|
130
|
-
...dnsServers.slice(0, -1).map((_, index) => ({
|
|
131
|
-
server: `remote-${index}`,
|
|
132
|
-
ip_accept_any: true,
|
|
133
|
-
})),
|
|
134
|
-
],
|
|
135
|
-
final: `remote-${dnsServers.length - 1}`,
|
|
136
|
-
strategy: "prefer_ipv4",
|
|
137
|
-
independent_cache: true,
|
|
138
|
-
},
|
|
139
|
-
inbounds: [
|
|
140
|
-
{
|
|
141
|
-
type: "tun",
|
|
142
|
-
tag: "tun-in",
|
|
143
|
-
stack: "gvisor",
|
|
144
|
-
address: [TUN_ADDRESS],
|
|
145
|
-
auto_route: true,
|
|
146
|
-
strict_route: true,
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
outbounds: [
|
|
150
|
-
{ type: "direct", tag: "direct" },
|
|
151
|
-
{ type: "block", tag: "block" },
|
|
152
|
-
],
|
|
153
|
-
route: {
|
|
154
|
-
default_interface: EGRESS_INTERFACE,
|
|
155
|
-
final: "block",
|
|
156
|
-
rules: [
|
|
157
|
-
{ action: "sniff" },
|
|
158
|
-
{ protocol: "dns", action: "hijack-dns" },
|
|
159
|
-
...buildAllowlistRules(allowlist),
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
return `${JSON.stringify(config, null, 2)}\n`;
|
|
164
|
-
}
|