@trim21/personal-pi-extensions 0.1.613 → 0.1.621
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 -2
- package/package.json +1 -1
- package/src/bwrap/README.md +57 -19
- package/src/bwrap/core.ts +4 -1
- package/src/bwrap/mihomo-config.ts +81 -40
- package/src/bwrap/network-stack.ts +39 -14
- package/src/bwrap/sandbox.ts +1 -1
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ fs(文件系统)与 network(网络)各自独立取值,可任意组合
|
|
|
52
52
|
| `network` | `limited` | 仅白名单可达:deny-by-default 过滤,`network.allowlist` 之外全部拒绝 |
|
|
53
53
|
| `network` | `allow-all` | 网络不受限 |
|
|
54
54
|
|
|
55
|
-
默认 `fs: workspace-write` + `network: block`。两者都 `allow-all` 时完全不经 bwrap、直接执行;其余组合都在 bwrap 里执行:`block` 用 `--unshare-net` 断网,`limited` 叠一层 mihomo TUN
|
|
55
|
+
默认 `fs: workspace-write` + `network: block`。两者都 `allow-all` 时完全不经 bwrap、直接执行;其余组合都在 bwrap 里执行:`block` 用 `--unshare-net` 断网,`limited` 叠一层 mihomo TUN(白名单 fakeip DNS)+ slirp4netns egress NAT,只有 allowlist 里的域名 / IP / CIDR 可达:未允许域名在 DNS 层即解析失败,裸 IP 连接在连接层被拒(allowlist 为空 = 全部拒绝)。条目匹配语义(精确 / `*.` 子域名)见 `src/bwrap/README.md`,进程模型与设计约束见同一文档。
|
|
56
56
|
|
|
57
57
|
### 提权机制
|
|
58
58
|
|
|
@@ -104,7 +104,8 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
|
|
|
104
104
|
"network": {
|
|
105
105
|
"mode": "block",
|
|
106
106
|
// limited 模式允许直连的域名 / IP / CIDR,可带 :port;空 = 全部拒绝
|
|
107
|
-
"
|
|
107
|
+
// 域名精确匹配,需要子域名时加 "*." 前缀("*.github.com" 不含 github.com 本身)
|
|
108
|
+
"allowlist": ["github.com", "*.github.com", "*.githubassets.com"],
|
|
108
109
|
// mihomo / slirp4netns 可执行文件路径(可选,缺省走 PATH)
|
|
109
110
|
"mihomoPath": "/usr/local/bin/mihomo",
|
|
110
111
|
"slirp4netnsPath": "/usr/bin/slirp4netns",
|
package/package.json
CHANGED
package/src/bwrap/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 进程模型
|
|
8
8
|
|
|
9
|
-
`network: limited`
|
|
9
|
+
`network: limited` 模式下,每条命令的网络栈进程树(宿主侧视角,共 4 个):
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
pi 进程(network-stack.ts)
|
|
@@ -21,7 +21,7 @@ pi 进程(network-stack.ts)
|
|
|
21
21
|
必须在宿主 netns 启动(原因见「设计约束」);持 exit-fd 读端 + tapfd。
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
每条命令的短命子树(命令结束即退):
|
|
25
25
|
|
|
26
26
|
```
|
|
27
27
|
nsenter -U -n --preserve-credentials -t <①的pid> \
|
|
@@ -29,22 +29,48 @@ nsenter -U -n --preserve-credentials -t <①的pid> \
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
nsenter 进入 holder 的 userns/netns,bwrap 在里面再嵌套创建自己的 user/pid
|
|
32
|
-
ns
|
|
32
|
+
ns 跑命令。网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
|
|
33
|
+
allowlist 变更因此即时生效,代价是每条命令重新启动一次 mihomo。
|
|
33
34
|
|
|
34
35
|
## 网络路径
|
|
35
36
|
|
|
36
|
-
- **mihomo(③)**:TUN(`auto-route` + `strict-route`)+ fakeip +
|
|
37
|
-
deny-by-default。`network.allowlist`
|
|
38
|
-
`
|
|
39
|
-
|
|
40
|
-
`
|
|
37
|
+
- **mihomo(③)**:TUN(`auto-route` + `strict-route`)+ **白名单 fakeip** +
|
|
38
|
+
deny-by-default。`network.allowlist` 的域名进 `fake-ip-filter`,配合
|
|
39
|
+
`fake-ip-filter-mode: whitelist` 即「只有这些域名拿 fake IP」;`dns.nameserver`
|
|
40
|
+
是 `rcode://name_error` 伪服务器,未命中白名单的域名(即未允许域名)落到它上面
|
|
41
|
+
即时拿到 NXDOMAIN(客户端报 `Could not resolve host`);连接层再用 `MATCH,REJECT`
|
|
42
|
+
兜底裸 IP 连接。`dns.direct-nameserver` 必须指向真实 DNS:连接由 fake IP 还原成
|
|
43
|
+
域名后,DIRECT 出站要按域名重新解析,否则会解析回 fake-ip 再进 TUN 成环。
|
|
44
|
+
这样两边都成立:未允许域名在 DNS 层就被拒,allowlist 域名的归属又由 fake IP
|
|
45
|
+
精确给出(不依赖嗅探、不受 DNS TTL 影响)。
|
|
41
46
|
- **slirp4netns(④)**:egress NAT。它 fork helper 进 netns 创建 tap0 并把
|
|
42
47
|
tapfd 传回主进程,真正的出站 socket 在宿主 netns。
|
|
43
48
|
- **interface-name: tap0**:mihomo 出站静态绑定 slirp 接口。不能用
|
|
44
49
|
`auto-detect-interface` 顶替——启动瞬间 tap0 可能尚未就绪,monitor 事后
|
|
45
50
|
纠正但 DNS 拨号已走错接口,上游查询进自己的 TUN 被 `dns-hijack` 自劫持。
|
|
46
51
|
- **mihomo `-d <uuid 目录>`**:cache.db 等落盘位置,放
|
|
47
|
-
`<agentDir>/tmp/mihomo-<uuid>/`,每次启动独立目录避免并发争抢 bbolt
|
|
52
|
+
`<agentDir>/tmp/mihomo-<uuid>/`,每次启动独立目录避免并发争抢 bbolt 锁;
|
|
53
|
+
停栈时删除,启动失败时保留作诊断材料。
|
|
54
|
+
|
|
55
|
+
## allowlist 条目
|
|
56
|
+
|
|
57
|
+
条目形式为「域名 / IPv4 / CIDR(可带 `:port`)」,域名有两档匹配精度——两层
|
|
58
|
+
(DNS 白名单与连接层规则)语义严格一致:
|
|
59
|
+
|
|
60
|
+
| 条目 | 匹配 | 不匹配 |
|
|
61
|
+
| ----------------------- | ------------------------------------------------ | ------------------------------------ |
|
|
62
|
+
| `example.com` | `example.com` | `www.example.com`、`a.b.example.com` |
|
|
63
|
+
| `*.example.com` | `www.example.com`、`a.b.example.com`(任意深度) | `example.com`、`notexample.com` |
|
|
64
|
+
| `example.com:443` | 精确域名 + 仅 443 | 其它端口 |
|
|
65
|
+
| `*.example.com:443` | 子域名 + 仅 443 | `example.com`、其它端口 |
|
|
66
|
+
| `1.2.3.4`、`10.0.0.0/8` | 对应 IP / 网段 | — |
|
|
67
|
+
|
|
68
|
+
- 裸域名是**精确匹配**(不是子树),需要「域名本身 + 子域名」同时放行时写两条。
|
|
69
|
+
- `*.` 必须占据完整的最左标签:`*`、`*example.com`、`a.*.example.com` 都是配置错误;
|
|
70
|
+
通配只对域名有效,IP 范围用 CIDR 表达。
|
|
71
|
+
- 端口只约束连接层(DNS 无端口语义):`example.com:443` 能解析,但只有 443 能连。
|
|
72
|
+
- 映射到 mihomo:精确条目在 DNS 侧是裸域名、连接层是 `DOMAIN`;通配条目在 DNS 侧
|
|
73
|
+
是 `.example.com`(dot-wildcard)、连接层是 `DOMAIN-WILDCARD,*.example.com`。
|
|
48
74
|
|
|
49
75
|
## 生命周期与清理
|
|
50
76
|
|
|
@@ -54,14 +80,16 @@ exit-fd(socketpair)是 slirp4netns 与 holder 之间唯一的生命周期绑
|
|
|
54
80
|
访问器(`stdio[3].fd` 恒为 undefined),只能经 `_handle.fd` 取原始 fd 再
|
|
55
81
|
dup 给 slirp4netns,且仅在子进程存活期间有效。
|
|
56
82
|
|
|
57
|
-
| 触发
|
|
58
|
-
|
|
|
59
|
-
| 正常 `stop()`
|
|
60
|
-
| pi 进程被 SIGKILL
|
|
61
|
-
| 单独 kill
|
|
62
|
-
| 单独 kill ②
|
|
83
|
+
| 触发 | 清理链路 |
|
|
84
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
85
|
+
| 正常 `stop()` | SIGTERM ④(先杀,它 pin 着 netns)→ SIGKILL ① → PDEATHSIG 给 ② SIGTERM → ② 杀 ③ 退出 → 内核清 pid ns |
|
|
86
|
+
| pi 进程被 SIGKILL | stdin 写端关闭 → ② EOF 自杀 → pid ns 清理 → exit-fd 写端关闭 → ④ HUP 自杀 → tapfd 释放 → netns 销毁 |
|
|
87
|
+
| 单独 kill ①(SIGKILL) | PDEATHSIG → ② SIGTERM → 同上;① wait 结束退出 → 写端全关 → ④ 退 |
|
|
88
|
+
| 单独 kill ② | pid-ns init 死 → 内核清 ③;① 退出 → 写端全关 → ④ 退 |
|
|
63
89
|
|
|
64
|
-
|
|
90
|
+
四条路径下网络栈全部进程收敛、netns 引用归零。注意 `--kill-child` 不是信号转发:
|
|
91
|
+
它的实现是 unshare fork 出的子进程给自己设 PDEATHSIG(unshare 死亡时收到 SIGTERM),
|
|
92
|
+
所以终止 ① 只能靠 SIGKILL(见「设计约束」第 5 条)。
|
|
65
93
|
|
|
66
94
|
## 设计约束与教训
|
|
67
95
|
|
|
@@ -74,13 +102,23 @@ dup 给 slirp4netns,且仅在子进程存活期间有效。
|
|
|
74
102
|
持 tapfd 泄漏 netns。
|
|
75
103
|
2. **tap fd pin 住 netns**:slirp4netns 持有 tapfd 期间 netns 不会销毁,
|
|
76
104
|
所以任何架构下 slirp4netns 的终止都必须显式保证(stop() / exit-fd)。
|
|
77
|
-
3. **fakeip
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
3. **DNS 层拒绝要靠收窄 fakeip,不能靠 DNS 规则**:mihomo 没有 `dns.rules`
|
|
106
|
+
这个字段(`config.RawDNS` 里不存在,写进配置会被静默忽略),`fake-ip` 分支
|
|
107
|
+
也没有按域名拒绝的钩子——命中 fakeip 就直接回合成 IP。要让未允许域名在 DNS
|
|
108
|
+
层失败,只能把它们排除在 fakeip 白名单之外(`fake-ip-filter-mode: whitelist`),
|
|
109
|
+
让查询落到 `nameserver`,再由 `rcode://name_error` 即时回 NXDOMAIN。
|
|
110
|
+
诊断时注意两点:allowlist 域名解析出 `198.18.x.x` 是**正常现象**(fakeip 生效),
|
|
111
|
+
未允许域名则应当**解析失败**而不是解析出 fakeip。
|
|
80
112
|
4. **诊断手段**:`pnpm sandbox --verbose` 透传 holder(mihomo/slirp4netns)
|
|
81
113
|
日志;`nsenter -U -n --preserve-credentials -t <holderPid>` 可手动进入
|
|
82
114
|
netns 用 AF_PACKET 抓 tap0 / 检查 `ip rule`(注意:沙盒里看不到宿主机
|
|
83
115
|
进程,宿主机诊断必须在沙盒外做)。
|
|
116
|
+
5. **终止 unshare 只能用 SIGKILL**。util-linux 的 unshare 在 fork 前
|
|
117
|
+
`sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码:
|
|
118
|
+
父进程永久阻塞这两个信号且不装 handler,发给它的 SIGTERM 只会 pending
|
|
119
|
+
永不投递(实测 3s 后仍存活)。曾因此在 `stop()` 里白等 `waitForExit`
|
|
120
|
+
的 2000ms 默认超时,把每条命令的沙箱开销从 ~180ms 抬到 ~2.09s。SIGKILL
|
|
121
|
+
立即生效,PDEATHSIG 再把 SIGTERM 交给 ② 走优雅退出。
|
|
84
122
|
|
|
85
123
|
## 调试入口
|
|
86
124
|
|
package/src/bwrap/core.ts
CHANGED
|
@@ -58,7 +58,10 @@ const fsConfigProperties = {
|
|
|
58
58
|
const networkConfigProperties = {
|
|
59
59
|
mode: StringEnum(NETWORK_MODES),
|
|
60
60
|
allowlist: Type.Array(
|
|
61
|
-
Type.String({
|
|
61
|
+
Type.String({
|
|
62
|
+
description:
|
|
63
|
+
'limited 模式允许直连的域名 / IP / CIDR,可带 :port;域名默认精确匹配,加 "*." 前缀表示其全部子域名(任意深度,不含该域名本身),如 "*.example.com"',
|
|
64
|
+
}),
|
|
62
65
|
),
|
|
63
66
|
mihomoPath: Type.Optional(Type.String()),
|
|
64
67
|
slirp4netnsPath: Type.Optional(Type.String()),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const FAKEIP_RANGE = "198.18.0.1/16";
|
|
2
|
+
/** mihomo 的拒绝伪 DNS 服务器:即时返回 NXDOMAIN,不查上游、不等超时。 */
|
|
3
|
+
const REJECT_NAMESERVER = "rcode://name_error";
|
|
2
4
|
/** TUN 与 slirp4netns tap0 共用;不对齐时大包会在 slirp NAT 后 PMTU blackhole。 */
|
|
3
5
|
export const TUN_MTU = 1500;
|
|
4
6
|
|
|
@@ -12,6 +14,8 @@ export interface MihomoConfigOptions {
|
|
|
12
14
|
interface AllowlistEntry {
|
|
13
15
|
readonly host: string;
|
|
14
16
|
readonly port?: number;
|
|
17
|
+
/** `*.host` 条目:匹配 host 的全部子域名(任意深度),不含 host 本身。 */
|
|
18
|
+
readonly subdomains: boolean;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/** mihomo 配置以 JSON 序列化输出(JSON 是 YAML 子集,-f 加载无差别)。 */
|
|
@@ -27,11 +31,15 @@ export interface MihomoConfig {
|
|
|
27
31
|
ipv6: false;
|
|
28
32
|
"enhanced-mode": "fake-ip";
|
|
29
33
|
"fake-ip-range": string;
|
|
34
|
+
/** whitelist:只有 fake-ip-filter 命中的域名才拿 fake IP,其余走 nameserver。 */
|
|
35
|
+
"fake-ip-filter-mode": "whitelist";
|
|
36
|
+
/** fake IP 白名单:allowlist 里的域名(带端口条目的域名也进)。 */
|
|
37
|
+
"fake-ip-filter"?: string[];
|
|
38
|
+
/** 默认解析服务器:拒绝伪服务器,未命中白名单的域名(即未允许域名)即时 NXDOMAIN。 */
|
|
30
39
|
nameserver: string[];
|
|
31
40
|
"default-nameserver": string[];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
rules: string[];
|
|
41
|
+
/** DIRECT 出站按域名解析用;不指定会解析回 fake-ip 再进 TUN 成环。 */
|
|
42
|
+
"direct-nameserver": string[];
|
|
35
43
|
};
|
|
36
44
|
tun: {
|
|
37
45
|
enable: true;
|
|
@@ -46,6 +54,8 @@ export interface MihomoConfig {
|
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/;
|
|
57
|
+
/** 子域名条目前缀:`*.example.com` 表示 example.com 的全部子域名(不含它本身)。 */
|
|
58
|
+
const WILDCARD_PREFIX = "*.";
|
|
49
59
|
/** 合法 DNS 主机名(标签 1-63 字符,字母数字加连字符,不得以连字符开头/结尾)。 */
|
|
50
60
|
const DOMAIN_PATTERN =
|
|
51
61
|
/^(?:[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])?$/;
|
|
@@ -73,14 +83,32 @@ function parsePort(value: string): number {
|
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/**
|
|
76
|
-
* 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port
|
|
77
|
-
*
|
|
86
|
+
* 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port;域名可加 `*.` 前缀表示
|
|
87
|
+
* 「它的全部子域名(任意深度,不含它本身)」;IPv6 必须用 [] 包裹(如 `[::1]:80`),
|
|
88
|
+
* 裸 IPv6 会报错提示补方括号。
|
|
78
89
|
*/
|
|
79
90
|
function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
91
|
+
if (entry === "*") {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Invalid allowlist entry "${entry}": "*" must be followed by a domain, e.g. "*.example.com"`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (entry.startsWith("*") && !entry.startsWith(WILDCARD_PREFIX)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Invalid allowlist entry "${entry}": "*" must occupy the whole leftmost label, e.g. "*.example.com"`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const subdomains = entry.startsWith(WILDCARD_PREFIX);
|
|
102
|
+
const body = subdomains ? entry.slice(WILDCARD_PREFIX.length) : entry;
|
|
103
|
+
if (body.length === 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Invalid allowlist entry "${entry}": "*" must be followed by a domain, e.g. "*.example.com"`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
80
108
|
let host: string;
|
|
81
109
|
let port: number | undefined;
|
|
82
|
-
if (
|
|
83
|
-
const match = /^\[(.+)\](?::(\d+))?$/.exec(
|
|
110
|
+
if (body.startsWith("[")) {
|
|
111
|
+
const match = /^\[(.+)\](?::(\d+))?$/.exec(body);
|
|
84
112
|
if (match?.[1] === undefined) {
|
|
85
113
|
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
86
114
|
}
|
|
@@ -89,18 +117,15 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
|
89
117
|
const portPart = match.at(2);
|
|
90
118
|
port = portPart === undefined ? undefined : parsePort(portPart);
|
|
91
119
|
} else {
|
|
92
|
-
const colon =
|
|
120
|
+
const colon = body.lastIndexOf(":");
|
|
93
121
|
if (colon === -1) {
|
|
94
|
-
|
|
95
|
-
throw new Error(`Invalid allowlist entry ""`);
|
|
96
|
-
}
|
|
97
|
-
host = entry;
|
|
122
|
+
host = body;
|
|
98
123
|
} else {
|
|
99
|
-
const portPart =
|
|
124
|
+
const portPart = body.slice(colon + 1);
|
|
100
125
|
if (!/^\d+$/.test(portPart)) {
|
|
101
126
|
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
102
127
|
}
|
|
103
|
-
host =
|
|
128
|
+
host = body.slice(0, colon);
|
|
104
129
|
if (host.length === 0) {
|
|
105
130
|
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
106
131
|
}
|
|
@@ -110,14 +135,24 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
|
110
135
|
port = parsePort(portPart);
|
|
111
136
|
}
|
|
112
137
|
}
|
|
138
|
+
if (host.includes("*")) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Invalid allowlist entry "${entry}": "*" is only allowed as the leftmost label, e.g. "*.example.com"`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
113
143
|
if (isIp(host)) {
|
|
144
|
+
if (subdomains) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Invalid allowlist entry "${entry}": "*" applies to domains only, use a CIDR entry for IP ranges`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
114
149
|
if (!IP_CHARS_PATTERN.test(host)) {
|
|
115
150
|
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
116
151
|
}
|
|
117
152
|
} else if (!DOMAIN_PATTERN.test(host)) {
|
|
118
153
|
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
119
154
|
}
|
|
120
|
-
return { host, port };
|
|
155
|
+
return { host, port, subdomains };
|
|
121
156
|
}
|
|
122
157
|
|
|
123
158
|
/** IP 条目的 mihomo 规则(IPv6 用 IP-CIDR6;no-resolve 跳过反向解析)。 */
|
|
@@ -129,35 +164,38 @@ function ipRule(host: string): string {
|
|
|
129
164
|
|
|
130
165
|
interface BuiltRules {
|
|
131
166
|
rules: string[];
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
dnsRules: string[];
|
|
167
|
+
/** 拿 fake IP 的域名:只有它们会进 fakeip 分支,其余域名落到默认解析(拒绝)。 */
|
|
168
|
+
fakeIpWhitelist: string[];
|
|
135
169
|
}
|
|
136
170
|
|
|
137
171
|
/**
|
|
138
172
|
* allowlist 条目 → mihomo 规则:
|
|
139
|
-
* - 域名(含 :port 条目里的域名)进 fake-ip-filter
|
|
140
|
-
*
|
|
173
|
+
* - 域名(含 :port 条目里的域名)进 fake-ip-filter(配合 filter-mode: whitelist,
|
|
174
|
+
* 即「只有这些域名拿 fake IP」):fake IP 与域名一一对应,连接到达时凭它精确还原
|
|
175
|
+
* 域名再匹配规则,不依赖嗅探、也不受 DNS TTL 影响;
|
|
176
|
+
* - 匹配精度两档:裸域名只匹配该域名本身(DOMAIN / 白名单里的裸域名),
|
|
177
|
+
* `*.` 前缀匹配它的全部子域名、不含它本身(DOMAIN-WILDCARD / dot-wildcard);
|
|
141
178
|
* - 无端口条目直接匹配;带端口条目用 AND 组合(域名/IP + DST-PORT)精确放行;
|
|
142
|
-
* - 最后以 MATCH,REJECT 兜底实现 deny-by-default
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
179
|
+
* - 最后以 MATCH,REJECT 兜底实现 deny-by-default(裸 IP 连接、绕过 DNS 的客户端)。
|
|
180
|
+
*
|
|
181
|
+
* 未进白名单的域名(即未允许域名)不拿 fake IP,直接落到 nameserver,被
|
|
182
|
+
* rcode://name_error 即时拒绝(客户端报 Could not resolve host)——mihomo 的 fakeip
|
|
183
|
+
* 分支本身没有按域名拒绝的钩子,收窄白名单是唯一能让拒绝发生在 DNS 层的办法。
|
|
146
184
|
*/
|
|
147
185
|
function buildRules(allowlist: readonly string[]): BuiltRules {
|
|
148
186
|
const rules: string[] = [];
|
|
149
|
-
const
|
|
150
|
-
const dnsRules: string[] = [];
|
|
187
|
+
const fakeIpWhitelist: string[] = [];
|
|
151
188
|
for (const entry of allowlist) {
|
|
152
|
-
const { host, port } = parseAllowlistEntry(entry);
|
|
189
|
+
const { host, port, subdomains } = parseAllowlistEntry(entry);
|
|
153
190
|
if (!isIp(host)) {
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
191
|
+
// DNS 侧与连接层必须表达同一集合:精确条目用裸域名 / DOMAIN,
|
|
192
|
+
// 子域名条目用 dot-wildcard(任意深度子域名,不含 apex)/ DOMAIN-WILDCARD
|
|
193
|
+
fakeIpWhitelist.push(subdomains ? `.${host}` : host);
|
|
194
|
+
const domainRule = subdomains ? `DOMAIN-WILDCARD,*.${host},DIRECT` : `DOMAIN,${host},DIRECT`;
|
|
157
195
|
if (port === undefined) {
|
|
158
|
-
rules.push(
|
|
196
|
+
rules.push(domainRule);
|
|
159
197
|
} else {
|
|
160
|
-
rules.push(`AND,(
|
|
198
|
+
rules.push(`AND,(${domainRule}),(DST-PORT,${port},DIRECT),DIRECT`);
|
|
161
199
|
}
|
|
162
200
|
} else if (port === undefined) {
|
|
163
201
|
rules.push(ipRule(host));
|
|
@@ -166,24 +204,26 @@ function buildRules(allowlist: readonly string[]): BuiltRules {
|
|
|
166
204
|
}
|
|
167
205
|
}
|
|
168
206
|
rules.push("MATCH,REJECT");
|
|
169
|
-
|
|
170
|
-
return { rules, fakeIpFilter, dnsRules };
|
|
207
|
+
return { rules, fakeIpWhitelist };
|
|
171
208
|
}
|
|
172
209
|
|
|
173
210
|
/**
|
|
174
|
-
* 生成 mihomo(Clash Meta)配置对象:TUN + fakeip + deny-by-default allowlist。
|
|
211
|
+
* 生成 mihomo(Clash Meta)配置对象:TUN + 白名单 fakeip + deny-by-default allowlist。
|
|
175
212
|
*
|
|
176
213
|
* - auto-detect-interface 让 mihomo 出站绑定 slirp4netns 的 tap0,否则它自己的
|
|
177
214
|
* DNS 查询会被 auto_route 送回 TUN 形成环;
|
|
178
215
|
* - TUN mtu 与 slirp4netns `--mtu` 共用 TUN_MTU,避免依赖各自默认值;
|
|
179
|
-
* - allowlist
|
|
216
|
+
* - fakeip 只服务 allowlist 域名(filter-mode: whitelist,见 buildRules 注释):
|
|
217
|
+
* 域名归属因此精确且不随 TTL 失效;
|
|
218
|
+
* - direct-nameserver 必须指向真实 DNS:连接由 fake IP 还原成域名后,DIRECT 出站要
|
|
219
|
+
* 按域名重新解析,不指定就会解析回 fake-ip 再进 TUN 成环。
|
|
180
220
|
*/
|
|
181
221
|
export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig {
|
|
182
222
|
const { allowlist, dnsServers } = options;
|
|
183
223
|
if (dnsServers.length === 0) {
|
|
184
224
|
throw new Error("At least one DNS server is required");
|
|
185
225
|
}
|
|
186
|
-
const { rules,
|
|
226
|
+
const { rules, fakeIpWhitelist } = buildRules(allowlist);
|
|
187
227
|
return {
|
|
188
228
|
"mixed-port": 0,
|
|
189
229
|
mode: "rule",
|
|
@@ -199,10 +239,11 @@ export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig
|
|
|
199
239
|
ipv6: false,
|
|
200
240
|
"enhanced-mode": "fake-ip",
|
|
201
241
|
"fake-ip-range": FAKEIP_RANGE,
|
|
202
|
-
|
|
242
|
+
"fake-ip-filter-mode": "whitelist",
|
|
243
|
+
...(fakeIpWhitelist.length > 0 && { "fake-ip-filter": fakeIpWhitelist }),
|
|
244
|
+
nameserver: [REJECT_NAMESERVER],
|
|
203
245
|
"default-nameserver": [...dnsServers],
|
|
204
|
-
|
|
205
|
-
rules: dnsRules,
|
|
246
|
+
"direct-nameserver": [...dnsServers],
|
|
206
247
|
},
|
|
207
248
|
tun: {
|
|
208
249
|
enable: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, readlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, readlink, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
@@ -94,6 +94,17 @@ function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 终止 holder(unshare 包装进程)必须用 SIGKILL:util-linux 的 unshare 在 fork 前
|
|
99
|
+
* `sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码——父进程永久
|
|
100
|
+
* 阻塞这两个信号,发给它的 SIGTERM 只会 pending 永不投递(实测 3s 后仍存活)。
|
|
101
|
+
* SIGKILL 不可阻塞,unshare 立即退出;其子进程(pid ns 的 init)经 --kill-child 的
|
|
102
|
+
* PDEATHSIG 收到 SIGTERM,走优雅退出并触发内核清理整个 pid ns。
|
|
103
|
+
*/
|
|
104
|
+
function killHolder(pid: number | undefined): void {
|
|
105
|
+
killProcess(pid, "SIGKILL");
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
/** 轮询等待进程退出(进程消失即返回)。 */
|
|
98
109
|
async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
|
|
99
110
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -107,7 +118,7 @@ async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
/**
|
|
121
|
+
/** 读取 holder 的直接子进程 pid(pid ns 的 init):PDEATHSIG 未生效时的 SIGKILL 兜底用。 */
|
|
111
122
|
async function readChildPids(pid: number): Promise<number[]> {
|
|
112
123
|
try {
|
|
113
124
|
const content = await readFile(`/proc/${pid}/task/${pid}/children`, "utf8");
|
|
@@ -235,14 +246,19 @@ export interface NetworkStack {
|
|
|
235
246
|
interface NetworkStackState {
|
|
236
247
|
holderPid: number;
|
|
237
248
|
slirpPid: number;
|
|
249
|
+
/** 过滤进程的工作目录:GC 兜底路径也要把它删掉。 */
|
|
250
|
+
mihomoHome: string;
|
|
238
251
|
}
|
|
239
252
|
|
|
240
|
-
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill
|
|
253
|
+
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程并清掉工作目录。 */
|
|
241
254
|
const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
242
|
-
//
|
|
255
|
+
// holder 退出触发内核清理 pid ns 内全部进程;
|
|
243
256
|
// slirp4netns 在宿主侧持有 tap fd,单独终止
|
|
244
|
-
|
|
257
|
+
killHolder(state.holderPid);
|
|
245
258
|
killProcess(state.slirpPid);
|
|
259
|
+
// FinalizationRegistry 回调不能 await:尽力而为,删不掉就留下(宿主崩溃时同样如此)
|
|
260
|
+
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
261
|
+
void rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
|
|
246
262
|
});
|
|
247
263
|
|
|
248
264
|
/**
|
|
@@ -264,7 +280,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
264
280
|
|
|
265
281
|
// mihomo 工作目录(-d):cache.db 等落在这里,而不是它默认的 ~/.config/mihomo/
|
|
266
282
|
//(后者不存在时 mihomo 每次启动都告警且 fakeip 映射无持久化)。每次启动用独立
|
|
267
|
-
// uuid 目录,避免并发的多个 holder 争抢 bbolt
|
|
283
|
+
// uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁;正常停栈与 GC 兜底删除该
|
|
284
|
+
// 目录(见 stop / stackFinalizer),启动失败时保留作诊断材料(见 catch)。
|
|
268
285
|
const mihomoHome = join(getAgentDir(), "tmp", `mihomo-${randomUUID()}`);
|
|
269
286
|
await mkdir(mihomoHome, { recursive: true });
|
|
270
287
|
|
|
@@ -275,7 +292,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
275
292
|
try {
|
|
276
293
|
// unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
|
|
277
294
|
// 内核都会清理 pid ns 内全部进程(mihomo),ns 引用随之归零;
|
|
278
|
-
// --kill-child=SIGTERM
|
|
295
|
+
// --kill-child=SIGTERM:init 拿到 PR_SET_PDEATHSIG,unshare 死亡时收到 SIGTERM
|
|
296
|
+
// 走优雅退出(注意 unshare 自身阻塞 SIGINT/SIGTERM,终止它只能靠 SIGKILL,见 killHolder)
|
|
279
297
|
holder = spawn(
|
|
280
298
|
"unshare",
|
|
281
299
|
[
|
|
@@ -371,7 +389,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
371
389
|
mihomoReady.catch(() => undefined);
|
|
372
390
|
});
|
|
373
391
|
|
|
374
|
-
const state: NetworkStackState = { holderPid, slirpPid: slirp.pid };
|
|
392
|
+
const state: NetworkStackState = { holderPid, slirpPid: slirp.pid, mihomoHome };
|
|
375
393
|
const stack: NetworkStack = {
|
|
376
394
|
exec: async (execOptions: NetworkStackExecOptions) => {
|
|
377
395
|
const child = spawn(
|
|
@@ -452,35 +470,42 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
452
470
|
// slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
|
|
453
471
|
// 先杀它再杀 holder,避免 stop() 与 exit-fd HUP 的收尾时序竞争
|
|
454
472
|
killProcess(state.slirpPid);
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
473
|
+
// SIGKILL holder → init 经 PDEATHSIG 收到 SIGTERM,优雅停 mihomo 后退出,
|
|
474
|
+
// 内核清理 pid ns 内全部进程,ns 引用随之归零(毫秒级,见 killHolder)
|
|
475
|
+
killHolder(state.holderPid);
|
|
458
476
|
await waitForExit(state.holderPid);
|
|
459
477
|
await waitForExit(state.slirpPid);
|
|
460
478
|
// 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
|
|
461
479
|
for (const pid of children) {
|
|
462
480
|
killProcess(pid, "SIGKILL");
|
|
463
481
|
}
|
|
482
|
+
// 工作目录只服务本次实例(mihomo 的 cache.db 等运行时缓存),随停栈删除;
|
|
483
|
+
// best-effort:删除失败不外抛,避免掩盖命令结果
|
|
484
|
+
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
485
|
+
await rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
|
|
464
486
|
},
|
|
465
487
|
holderPid,
|
|
466
488
|
};
|
|
467
489
|
stackFinalizer.register(stack, state);
|
|
468
490
|
return stack;
|
|
469
491
|
} catch (error) {
|
|
470
|
-
// 失败清理:holder(unshare
|
|
471
|
-
//
|
|
492
|
+
// 失败清理:SIGKILL holder(unshare)→ init 经 PDEATHSIG 收到 SIGTERM 后退出,
|
|
493
|
+
// 内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
|
|
472
494
|
//(exit-fd 写端也会随 holder 死亡关闭,这里主动杀只是不等到 HUP 轮询)
|
|
473
495
|
if (slirp?.pid) {
|
|
474
496
|
killProcess(slirp.pid);
|
|
475
497
|
}
|
|
476
498
|
if (holder?.pid) {
|
|
477
499
|
const children = await readChildPids(holder.pid);
|
|
478
|
-
|
|
500
|
+
killHolder(holder.pid);
|
|
479
501
|
await waitForExit(holder.pid);
|
|
480
502
|
for (const pid of children) {
|
|
481
503
|
killProcess(pid, "SIGKILL");
|
|
482
504
|
}
|
|
483
505
|
}
|
|
506
|
+
// 这里刻意不删 mihomoHome:启动失败时它属于现场材料,与下面落盘的诊断日志
|
|
507
|
+
//(holder / slirp 输出 + 错误本身)配套保留,便于事后排查;失败路径罕见,
|
|
508
|
+
// 留一个目录不构成泄漏
|
|
484
509
|
const logPath = await writeFailureLog(error, [holderLog.join(""), slirpLog.join("")]);
|
|
485
510
|
if (logPath !== undefined && error instanceof Error) {
|
|
486
511
|
throw new Error(`${error.message}\n(sandbox startup diagnostics: ${logPath})`, {
|
package/src/bwrap/sandbox.ts
CHANGED
|
@@ -117,7 +117,7 @@ export async function runInSandbox(
|
|
|
117
117
|
const workspace = expandHome(options.workspace);
|
|
118
118
|
const commandCwd = expandHome(options.commandCwd ?? workspace);
|
|
119
119
|
const local = options.unsandboxed === true || !resolved.bwrapEnabled;
|
|
120
|
-
//
|
|
120
|
+
// 每次执行现建现停网络栈(起栈 ~40ms、停栈 ~100ms),作用域结束即停栈:allowlist 变更即时生效
|
|
121
121
|
const stack = local ? undefined : await createNetworkStack(resolved, options.log);
|
|
122
122
|
try {
|
|
123
123
|
if (stack) {
|