在 Mac 上抛弃 mihomo 重回 sing-box
前言
又回到最初的起点,呆呆地站在镜子前
如前文所述,Mac 上的代理工具实在都是一坨狗屎,天天断网真的难受啊家人们,裸核跑 mihomo 照样有时候断网。好在被我找到了原因:
- macOS 上的
dns-hijack只能处理已经进入 TUN 的数据包 - macOS 系统解析器有时会把 DNS 查询直接绑定到物理网卡,或者发往局域网网关 DNS,使数据包根本不经过 TUN
dns 劫持,将匹配到的连接导入内部 dns 模块,不书写协议则为 udp://
- 在
MacOS/Windows无法自动劫持发往局域网的 dns 请求 - 在
Android如开启私人dns则无法自动劫持 dns 请求
- 问题原因:
Tun无法劫持 macOS 发往LAN(局域网) 的 DNS 请求,参考文档 MihomoDocs。
Verge会在启动 Tun 时修改系统 DNS 为223.6.6.6,以保证 Tun 正常工作。关闭 Tun 即恢复原来的系统 DNS。Tun 下 DNS 由 mihomo 核心代理,因此这个 DNS 并没有其它意义。
解决方案
所以解决方案就是把系统 DNS 修改为公网 DNS,这样 mihomo 内核就能正确将 DNS 劫持进 Tun 了,但是这样子我还是会偶尔断网。然后我发现 sing-box 官方 macOS 客户端通过 Apple 的 NetworkExtension 创建 TUN,而不是单纯用 root 创建一个 utun。这种方式可以由系统 VPN 框架同时配置隧道路由和 DNS resolver。
Service
SFI/SFM/SFT allows you to run sing-box through NetworkExtension with Application Extension or System Extension.
TUN
SFI/SFM/SFT provides an unprivileged TUN implementation through NetworkExtension.
| TUN inbound option | Available | Note |
|---|---|---|
interface_name |
️ | Managed by Darwin |
inet4_address |
/ | |
inet6_address |
/ | |
mtu |
/ | |
gso |
Not implemented | |
auto_route |
/ | |
strict_route |
️ | Not implemented |
inet4_route_address |
/ | |
inet6_route_address |
/ | |
inet4_route_exclude_address |
/ | |
inet6_route_exclude_address |
/ | |
endpoint_independent_nat |
/ | |
stack |
/ | |
include_interface |
️ | Not implemented |
exclude_interface |
️ | Not implemented |
include_uid |
️ | Not implemented |
exclude_uid |
️ | Not implemented |
include_android_user |
️ | Not implemented |
include_package |
️ | Not implemented |
exclude_package |
️ | Not implemented |
platform |
/ |
| Route/DNS rule option | Available | Note |
|---|---|---|
process_name |
Only supported in the macOS standalone and iOS jailbreak versions | |
process_path |
Only supported in the macOS standalone and iOS jailbreak versions | |
process_path_regex |
Only supported in the macOS standalone and iOS jailbreak versions | |
package_name |
/ | |
package_name_regex |
/ | |
user |
Only supported in the macOS standalone and iOS jailbreak versions | |
user_id |
Only supported in the macOS standalone and iOS jailbreak versions | |
wifi_ssid |
Only supported on iOS | |
wifi_bssid |
Only supported on iOS |
迁移至 sing-box
安装
1
brew install sfm
将 Clash 规则转换成 sing-box 规则,这个让 AI 写一下就行了,参考一下我的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373name: Build sing-box SRS rules
on:
push:
branches:
- main
paths:
- "Rule/Block.txt"
- "Rule/Direct.txt"
- "Rule/GFW.txt"
- ".github/workflows/build-sing-box-rules.yml"
schedule:
# 每 6 小时检查一次远程规则;UTC 时间
- cron: "17 */6 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: build-sing-box-rules-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: [self-hosted, linux, ARM64]
env:
SING_BOX_VERSION: "1.13.14"
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Python dependencies
run: python -m pip install --disable-pip-version-check requests PyYAML
- name: Download sing-box
shell: bash
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64|amd64)
sing_box_arch="amd64"
;;
aarch64|arm64)
sing_box_arch="arm64"
;;
*)
echo "::error::Unsupported architecture: $(uname -m)"
exit 1
;;
esac
echo "Runner architecture: $(uname -m)"
echo "sing-box architecture: ${sing_box_arch}"
archive="sing-box-${SING_BOX_VERSION}-linux-${sing_box_arch}.tar.gz"
directory="sing-box-${SING_BOX_VERSION}-linux-${sing_box_arch}"
url="https://github.com/SagerNet/sing-box/releases/download/v${SING_BOX_VERSION}/${archive}"
curl -fsSL \
--retry 5 \
--retry-all-errors \
"$url" \
-o "$archive"
tar -xzf "$archive"
mkdir -p .bin
install -m 0755 \
"${directory}/sing-box" \
.bin/sing-box
echo "$PWD/.bin" >> "$GITHUB_PATH"
file .bin/sing-box
.bin/sing-box version
- name: Convert local TXT and remote YAML rules
shell: python
run: |
import json
import re
import time
from pathlib import Path
import requests
import yaml
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
RULE_SET_VERSION = 4
LOCAL_RULES = {
"Liqiye_Block": Path("Rule/Block.txt"),
"Liqiye_Direct": Path("Rule/Direct.txt"),
"Liqiye_GFW": Path("Rule/GFW.txt"),
}
# (输出文件名, 远程地址)
REMOTE_RULES = {
"LAN_SPLITTER": "https://kelee.one/Tool/Clash/Rule/LAN_SPLITTER.yaml",
"Direct": "https://kelee.one/Tool/Clash/Rule/Direct.yaml",
"Proxy": "https://kelee.one/Tool/Clash/Rule/Proxy.yaml",
"ChinaDownloadCDN": "https://kelee.one/Tool/Clash/Rule/ChinaDownloadCDN.yaml",
"InternationalDownloadCDN": "https://kelee.one/Tool/Clash/Rule/InternationalDownloadCDN.yaml",
"Bahamut": "https://rule.kelee.one/Clash/Bahamut.yaml",
"Spotify": "https://rule.kelee.one/Clash/Spotify.yaml",
"AI": "https://kelee.one/Tool/Clash/Rule/AI.yaml",
"Steam": "https://rule.kelee.one/Clash/Steam.yaml",
"Game": "https://kelee.one/Tool/Clash/Rule/Game.yaml",
"iCloudChina": "https://kelee.one/Tool/Clash/Rule/iCloudChina.yaml",
"ApplePushNotificationService": "https://kelee.one/Tool/Clash/Rule/ApplePushNotificationService.yaml",
"AppleSoftwareUpdates": "https://kelee.one/Tool/Clash/Rule/AppleSoftwareUpdates.yaml",
"AppleAccount": "https://kelee.one/Tool/Clash/Rule/AppleAccount.yaml",
"AppStore": "https://kelee.one/Tool/Clash/Rule/AppStore.yaml",
"TestFlight": "https://rule.kelee.one/Clash/TestFlight.yaml",
"ESET_China": "https://kelee.one/Tool/Clash/Rule/ESET_China.yaml",
}
FIELD_MAP = {
"DOMAIN": "domain",
"DOMAIN-SUFFIX": "domain_suffix",
"DOMAIN-KEYWORD": "domain_keyword",
"DOMAIN-REGEX": "domain_regex",
"IP-CIDR": "ip_cidr",
"IP-CIDR6": "ip_cidr",
"SRC-IP-CIDR": "source_ip_cidr",
"PROCESS-NAME": "process_name",
"PROCESS-PATH": "process_path",
"PROCESS-PATH-REGEX": "process_path_regex",
"PACKAGE-NAME": "package_name",
}
build_dir = Path("build/sing-box-rules")
download_dir = Path("build/remote-yaml")
build_dir.mkdir(parents=True, exist_ok=True)
download_dir.mkdir(parents=True, exist_ok=True)
def make_session():
retry = Retry(
total=4,
connect=4,
read=4,
status=4,
backoff_factor=2,
status_forcelist=(408, 425, 429, 500, 502, 503, 504),
allowed_methods=frozenset(("GET",)),
raise_on_status=False,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
session.headers.update({
"User-Agent": "clash.meta/1.19.0",
"Accept": "application/yaml,text/yaml,text/plain,*/*",
"Referer": "https://kelee.one/",
})
return session
session = make_session()
def split_option(text):
"""移除 Clash 规则末尾常见的 no-resolve 参数。"""
value = text.strip()
if value.lower().endswith(",no-resolve"):
value = value[: -len(",no-resolve")].rstrip()
return value
def parse_ports(value, source):
ports = []
ranges = []
for item in re.split(r"[/|]", value):
item = item.strip()
if not item:
continue
if "-" in item or ":" in item:
item = item.replace("-", ":")
if not re.fullmatch(r"\d*:\d*", item):
raise ValueError(f"{source}: invalid port range: {item}")
ranges.append(item)
else:
try:
port = int(item)
except ValueError as error:
raise ValueError(
f"{source}: invalid port: {item}"
) from error
if not 0 <= port <= 65535:
raise ValueError(f"{source}: port out of range: {port}")
ports.append(port)
if not ports and not ranges:
raise ValueError(f"{source}: empty port rule")
return ports, ranges
def parse_rule(raw_rule, source):
if not isinstance(raw_rule, str):
raise TypeError(f"{source}: rule is not a string: {raw_rule!r}")
text = raw_rule.strip()
if not text or text.startswith(("#", ";", "//")):
return None
if "," not in text:
raise ValueError(f"{source}: invalid classical rule: {text}")
rule_type, value = text.split(",", 1)
rule_type = rule_type.strip().upper()
# sing-box 无法按 HTTP User-Agent 路由。
# 只明确跳过该类型,其他未知类型继续报错,避免静默生成错误规则。
if rule_type == "USER-AGENT":
print(f"Skipped unsupported USER-AGENT rule: {source}")
return None
value = split_option(value)
if not value:
raise ValueError(f"{source}: empty rule value: {text}")
if rule_type in FIELD_MAP:
return {FIELD_MAP[rule_type]: [value]}
if rule_type == "NETWORK":
network = value.lower()
if network not in {"tcp", "udp", "icmp"}:
raise ValueError(f"{source}: unsupported network: {value}")
return {"network": [network]}
if rule_type in {"DST-PORT", "SRC-PORT"}:
ports, ranges = parse_ports(value, source)
result = {}
if rule_type == "DST-PORT":
if ports:
result["port"] = ports
if ranges:
result["port_range"] = ranges
else:
if ports:
result["source_port"] = ports
if ranges:
result["source_port_range"] = ranges
return result
raise ValueError(
f"{source}: unsupported Clash rule type {rule_type!r}. "
"The workflow stops instead of silently producing an incorrect SRS."
)
def build_source(name, raw_rules):
rules = []
seen = set()
for line_number, raw_rule in enumerate(raw_rules, start=1):
rule = parse_rule(raw_rule, f"{name}:{line_number}")
if rule is None:
continue
key = json.dumps(rule, ensure_ascii=False, sort_keys=True)
if key in seen:
continue
seen.add(key)
rules.append(rule)
if not rules:
raise ValueError(f"{name}: no usable rules found")
output = build_dir / f"{name}.json"
output.write_text(
json.dumps(
{"version": RULE_SET_VERSION, "rules": rules},
ensure_ascii=False,
indent=2,
) + "\n",
encoding="utf-8",
)
print(f"Generated {output}: {len(rules)} rules")
# 转换仓库内的三个 TXT 文件
for output_name, input_path in LOCAL_RULES.items():
lines = input_path.read_text(encoding="utf-8-sig").splitlines()
build_source(output_name, lines)
# 下载并转换远程 Clash classical YAML
for output_name, url in REMOTE_RULES.items():
print(f"Downloading {url}")
response = session.get(url, timeout=(20, 180))
response.raise_for_status()
downloaded_file = download_dir / f"{output_name}.yaml"
downloaded_file.write_bytes(response.content)
try:
document = yaml.safe_load(response.content.decode("utf-8-sig"))
except (UnicodeDecodeError, yaml.YAMLError) as error:
raise ValueError(f"{url}: invalid YAML") from error
if not isinstance(document, dict):
raise ValueError(f"{url}: YAML root must be an object")
payload = document.get("payload")
if not isinstance(payload, list):
raise ValueError(f"{url}: missing YAML payload list")
build_source(output_name, payload)
time.sleep(1)
output_names = [*LOCAL_RULES.keys(), *REMOTE_RULES.keys()]
Path("build/output-names.txt").write_text(
"\n".join(output_names) + "\n",
encoding="utf-8",
)
- name: Compile SRS files
shell: bash
run: |
set -euo pipefail
mkdir -p sing-box/Rule
while IFS= read -r name; do
[ -n "$name" ] || continue
sing-box rule-set compile \
--output "sing-box/Rule/${name}.srs" \
"build/sing-box-rules/${name}.json"
done < build/output-names.txt
ls -lh sing-box/Rule/*.srs
- name: Commit generated SRS files
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email \
"41898282+github-actions[bot]@users.noreply.github.com"
while IFS= read -r name; do
[ -n "$name" ] || continue
git add "sing-box/Rule/${name}.srs"
done < build/output-names.txt
if git diff --cached --quiet; then
echo "SRS files are already up to date."
exit 0
fi
git commit -m "chore: update sing-box rule sets"
# 定时任务执行期间仓库可能出现新提交,先同步再推送。
git pull --rebase origin main
git push origin HEAD:main由于 sing-box 不支持订阅,所以我们先写个不带出站的模版配置,我的配置是纯 IPv4 + Tun + RealIP。不用 IPv6 除了分流覆盖不全外,也是排除一切可能让我断网的可能
1
2
3
4
5
6
7
8
9
10
11# 禁用 Wi-Fi IPv6
sudo networksetup -setv6off Wi-Fi
# 禁用 以太网 IPv6
sudo networksetup -setv6off Ethernet
# 启用 Wi-Fi IPv6
sudo networksetup -setv6automatic Wi-Fi
# 启用 以太网 IPv6
sudo networksetup -setv6automatic Ethernet订阅的话我们就用 Sub-Store
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16services:
sub-store:
image: xream/sub-store:http-meta
container_name: sub-store
restart: always
network_mode: host
environment:
SUB_STORE_BACKEND_API_HOST: 0.0.0.0
SUB_STORE_BACKEND_API_PORT: 3001
SUB_STORE_BACKEND_MERGE: true
SUB_STORE_FRONTEND_BACKEND_PATH: /2cXaAxRGfddmGz2yx1wA
# HTTP-META 的, 一般不用改
PORT: 9876
HOST: 0.0.0.0
volumes:
- ./data:/opt/app/data在「文件」中将模板配置复制进去,订阅用大括号表示即可:
{订阅当中的名称},比如:1
2
3"outbounds": [
{"type": "direct", "tag": "direct"},
{"type": "selector", "tag": "📡 节点选择", "outbounds": ["{订阅当中的名称}", "👆 手动选择", "🇭🇰 香港节点", "🇯🇵 日本节点", "🇸🇬 狮城节点", "🇺🇸 美国节点"]},「操作」选「脚本操作」,填入以下转换脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211const scope = "SING-BOX-CONFIG";
const source =
typeof $content === "string" && $content.trim()
? $content
: typeof $files !== "undefined" && Array.isArray($files)
? $files.find((file) => typeof file === "string" && file.trim())
: undefined;
if (!source) {
throw new Error(`[${scope}] 没有读到模板内容`);
}
const parser =
typeof ProxyUtils !== "undefined" && ProxyUtils.JSON5
? ProxyUtils.JSON5
: JSON;
let config;
try {
config = parser.parse(source.replace(/^\uFEFF/, ""));
} catch (error) {
throw new Error(`[${scope}] 模板不是有效的 JSON/JSON5: ${error.message}`);
}
if (!Array.isArray(config.outbounds)) {
throw new Error(`[${scope}] 模板缺少 outbounds 数组`);
}
const placeholderPattern = /^\{([^{}]+)\}$/;
const subscriptionNames = [];
for (const outbound of config.outbounds) {
if (!Array.isArray(outbound.outbounds)) continue;
for (const item of outbound.outbounds) {
if (typeof item !== "string") continue;
const match = item.match(placeholderPattern);
if (match && !subscriptionNames.includes(match[1])) {
subscriptionNames.push(match[1]);
}
}
}
if (!subscriptionNames.length) {
throw new Error(`[${scope}] 模板中没有找到 {订阅名} 占位符`);
}
const produced = await Promise.all(
subscriptionNames.map(async (name) => {
let outbounds;
try {
outbounds = await produceArtifact({
type: "subscription",
name,
platform: "sing-box",
produceType: "internal",
});
} catch (error) {
throw new Error(
`[${scope}] 读取订阅 "${name}" 失败: ${error.message}`,
);
}
if (!Array.isArray(outbounds) || !outbounds.length) {
throw new Error(`[${scope}] 订阅 "${name}" 没有可用节点`);
}
return [name, outbounds.map((outbound) => ({ ...outbound }))];
}),
);
const templateTags = new Set(
config.outbounds
.map((outbound) => outbound && outbound.tag)
.filter((tag) => typeof tag === "string" && tag),
);
const usedTags = new Set(templateTags);
const subscriptionTags = new Map();
const subscriptionNodes = [];
function uniqueTag(tag, subscriptionName) {
if (!usedTags.has(tag)) return tag;
const qualified = `[${subscriptionName}] ${tag}`;
if (!usedTags.has(qualified)) return qualified;
let index = 2;
while (usedTags.has(`${qualified} ${index}`)) index += 1;
return `${qualified} ${index}`;
}
for (const [subscriptionName, nodes] of produced) {
const renameMap = new Map();
for (const node of nodes) {
if (!node || typeof node !== "object") {
throw new Error(`[${scope}] 订阅 "${subscriptionName}" 包含无效节点`);
}
if (typeof node.tag !== "string" || !node.tag.trim()) {
throw new Error(
`[${scope}] 订阅 "${subscriptionName}" 存在没有 tag 的节点`,
);
}
const originalTag = node.tag;
const tag = uniqueTag(originalTag, subscriptionName);
renameMap.set(originalTag, tag);
node.tag = tag;
usedTags.add(tag);
}
for (const node of nodes) {
if (typeof node.detour === "string" && renameMap.has(node.detour)) {
node.detour = renameMap.get(node.detour);
}
}
subscriptionTags.set(
subscriptionName,
nodes.map((node) => node.tag),
);
subscriptionNodes.push(...nodes);
}
function applyFilters(tags, filters, selectorTag) {
if (!Array.isArray(filters) || !filters.length) return tags;
let result = [...tags];
for (const filter of filters) {
const keywords = Array.isArray(filter.keywords) ? filter.keywords : [];
const patterns = keywords.map((keyword) => {
try {
return new RegExp(keyword, "i");
} catch (error) {
throw new Error(
`[${scope}] 策略组 "${selectorTag}" 的过滤正则无效: ${keyword}`,
);
}
});
if (!patterns.length) continue;
if (filter.action === "include") {
result = result.filter((tag) =>
patterns.some((pattern) => pattern.test(tag)),
);
} else if (filter.action === "exclude") {
result = result.filter(
(tag) => !patterns.some((pattern) => pattern.test(tag)),
);
}
}
// Keep the selector valid when a region/filter has no matching nodes.
return result.length ? result : tags;
}
const fixedOutbounds = [];
const selectors = [];
for (const outbound of config.outbounds) {
if (outbound.type !== "selector") {
fixedOutbounds.push(outbound);
continue;
}
const expanded = [];
for (const item of outbound.outbounds || []) {
const match = typeof item === "string" && item.match(placeholderPattern);
if (!match) {
expanded.push(item);
continue;
}
const tags = subscriptionTags.get(match[1]);
if (!tags) {
throw new Error(
`[${scope}] 策略组 "${outbound.tag}" 引用了未知订阅 "${match[1]}"`,
);
}
expanded.push(...applyFilters(tags, outbound.filter, outbound.tag));
}
outbound.outbounds = [...new Set(expanded)];
delete outbound.filter;
if (!outbound.outbounds.length) {
throw new Error(`[${scope}] 策略组 "${outbound.tag}" 没有可用出站`);
}
selectors.push(outbound);
}
config.outbounds = [...fixedOutbounds, ...subscriptionNodes, ...selectors];
const finalTags = new Set(config.outbounds.map((outbound) => outbound.tag));
for (const selector of selectors) {
const missing = selector.outbounds.filter((tag) => !finalTags.has(tag));
if (missing.length) {
throw new Error(
`[${scope}] 策略组 "${selector.tag}" 引用了不存在的出站: ${missing.join(
", ",
)}`,
);
}
}
console.log(
`[${scope}] INFO: 已注入 ${subscriptionNodes.length} 个节点,来源: ${subscriptionNames.join(
", ",
)}`,
);
$content = JSON.stringify(config, null, 2);
分享出去即可拿到链接







