feat: 多订阅 / 地区分组 / 回国模式 / 多入站 / GEO 管理

多订阅:
- 向导的订阅那步改成可以连续填多个 (最多 8 个, 回车结束),每个一条独立的
  proxy-providers,节点合并进同一组
- 订阅行标记升级成 "# mihomo-up:sub:<provider名>",配置菜单里可按序号改任意一条
- 改完顺手删掉对应的节点缓存,否则内核会继续用旧节点

地区分组:
- 支持 HK TW JP SG US KR UK DE FR,每个地区一个 url-test 组,靠节点名关键词
  匹配 (中英文 + 旗帜 emoji)
- 提醒: 某地区匹配不到任何节点会让内核拒绝启动,紧接着的校验会指出来

分流规则从两种变三种:
- 3 = 指定地区走代理、其余直连,也就是回国 / 分区代理
- 回国模式生成 .mrs 规则集而不是 GEOIP: 域名维度打头,IP 维度兜底。
  国内站挂境外 CDN 时解析出的 IP 不在本国段,只靠 GEOIP 会整片漏掉
- 只有 CN 有成体系的域名列表,其它国家代码只能按 IP 匹配,脚本会当场说明后果

多入站 (listeners):
- 新增「入站端口」菜单: 加/删额外的本地端口,每个可绑一个策略组、DIRECT,
  或按 rules 分流
- 每条写成一行 YAML 流式映射并带标记,加删就是加一行删一行,不动别处
- 拦截端口冲突和非法类型;选了具体出站会提醒它完全跳过 rules
- 删到最后一条时连空的 listeners: 标题一起去掉

GEO / 规则集:
- 新增菜单: 列出 geoip.dat / geosite.dat / *.mmdb / rules/*.mrs 的大小和
  更新时间。下载失败是静默的 —— 规则全不匹配、流量落到 MATCH,看起来像
  "配置没生效",所以状态必须可见
- 立即更新优先走管理接口 POST /configs/geo,不重启内核;接口不可用时回退到
  删文件 + 重启
- 自动更新开关写 geo-auto-update / geo-update-interval

验证: 三种规则模式生成的配置都用 PyYAML 解析通过,分组、rule-providers、
listeners 结构和字段类型均符合预期;两个 .mrs 地址实测 200。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpjLPV4JvAsuq9ACb3u6B4
This commit is contained in:
2026-08-09 17:55:59 +08:00
co-authored by Claude Opus 5
parent 06a42149c6
commit e77ef3045a
2 changed files with 534 additions and 88 deletions
+78 -7
View File
@@ -58,7 +58,7 @@ sudo ./mihomo-up.sh shortcut # 只安装 / 刷新 mihomo-up 快捷命令
1. 一键安装 内核 + systemd + 配置,缺哪步补哪步 1. 一键安装 内核 + systemd + 配置,缺哪步补哪步
2. 检查更新 3. 升级内核 4. 安装指定版本内核 2. 检查更新 3. 升级内核 4. 安装指定版本内核
5. 回滚内核 6. 查看内核备份 5. 回滚内核 6. 查看内核备份
7. 配置管理 更换订阅 / 修改端口 / 局域网共享 / 面板密钥 / 导入配置 / 校验 / 编辑 / 备份还原 7. 配置管理 订阅 / 端口 / 局域网 / 面板密钥 / 入站端口 / GEO / 导入 / 校验 / 编辑 / 备份
8. 服务控制 启动 / 停止 / 重启 / 状态 / 最近日志 / 开机自启 / 重写服务文件 8. 服务控制 启动 / 停止 / 重启 / 状态 / 最近日志 / 开机自启 / 重写服务文件
9. 卸载 9. 卸载
``` ```
@@ -102,21 +102,92 @@ fork 之后想让快捷命令指向你自己那份,安装的时候加 `--scrip
| 问题 | 说明 | | 问题 | 说明 |
|---|---| |---|---|
| 订阅链接 | 机场给的 http(s) 地址,写成 `proxy-providers`,每 24 小时自动更新一次 | | 订阅链接 | 机场给的 http(s) 地址。**可以填多个**,一次一个,回车结束(最多 8 个) |
| 本地代理端口 | `mixed-port`,HTTP 和 SOCKS5 共用,默认 7890 | | 本地代理端口 | `mixed-port`,HTTP 和 SOCKS5 共用,默认 7890 |
| 局域网共享 | `allow-lan`,开了同网段其它设备也能连(还要放行防火墙) | | 局域网共享 | `allow-lan`,开了同网段其它设备也能连(还要放行防火墙) |
| 管理接口 | 只允许本机 / 局域网公网可访问 / 不开启。面板和 API 都连这里 | | 管理接口 | 只允许本机 / 局域网公网可访问 / 不开启。面板和 API 都连这里 |
| 访问密钥 | 回车即自动生成随机串;管理接口开在 `0.0.0.0` 时务必设 | | 访问密钥 | 回车即自动生成随机串;管理接口开在 `0.0.0.0` 时务必设 |
| Web 面板 | 选是就写 `external-ui-url`,内核首次启动自己去下载 metacubexd | | Web 面板 | 选是就写 `external-ui-url`,内核首次启动自己去下载 metacubexd |
| 分流规则 | 全部走代理(默认),或者指定某个国家/地区直连、其余走代理 | | 地区分组 | 留空=不拆。填 `HK JP US` 之类就按地区各建一个自动测速组 |
| 分流规则 | 三选一,见下面的表 |
生成的分组是 `PROXY`(手动选,默认跟着 AUTO)和 `AUTO`(自动挑延迟最低的)。规则里内网地址一律直连。 多个订阅的节点会合并进同一组。每个订阅是一条独立的 `proxy-providers`,各自按 24 小时周期更新。
选了「指定国家/地区直连」会额外写一条 `GEOIP,<两位国家代码>,DIRECT`,代码填 `CN`、`US`、`JP`、`DE` 都行,小写会自动转大写。 **地区分组**支持 `HK TW JP SG US KR UK DE FR`。每个地区一个 `url-test` 组,靠节点名里的关键词匹配,中英文和旗帜 emoji 都认。
**修改配置不用手写 YAML**:菜单「配置管理」能直接更换订阅、修改端口、开关局域网共享、改面板密钥。 注意:某个地区一个节点都没匹配上的话内核会拒绝启动,紧接着的校验会当场告诉你。
每次改动之前自动备份成 `config.yaml.bak-<时间戳>`,改完立刻用内核 `-t` 校验。**校验不过会问你要不要还原**,服务正在运行的话再问一句要不要重启生效。 **分流规则**三选一,内网地址在哪种模式下都直连:
| 模式 | 生成的规则 | 用途 |
|---|---|---|
| 1 全部走代理 | `MATCH,PROXY` | 默认 |
| 2 指定地区直连、其余走代理 | `GEOIP,<CC>,DIRECT` + `MATCH,PROXY` | 常见的翻墙用法 |
| 3 指定地区走代理、其余直连 | `RULE-SET,...` + `MATCH,DIRECT` | 回国 / 分区代理 |
地区填两位 ISO 代码(`CN`、`US`、`JP`、`DE`…),小写自动转大写。
生成的分组是 `PROXY`(总开关,手动选,默认跟着 AUTO)、`AUTO`(全部节点里自动测速),加上你要的那几个地区组。
## 回国 / 分区代理
上面的模式 3 是反过来的:命中的地区走代理,其余直连。适合人在境外、只想让国内站点走国内节点。
它不用 `GEOIP`,而是生成两条 `.mrs` 规则集:
```yaml
rules:
- RULE-SET,cn-domain,PROXY # 域名维度
- RULE-SET,cn-ip,PROXY,no-resolve # IP 维度兜底
- MATCH,DIRECT
```
**为什么必须有域名那份**:很多国内站挂着境外 CDN,域名解析出来的 IP 不在国内段,只靠 `GEOIP` 会整片漏掉。域名列表才是主力,IP 那条只兜住「直接连 IP、没有域名」的连接。
`.mrs` 是二进制规则集,只下载你要用的那一两个列表,不用拖整包 `geosite.dat`。下载走 `proxy: DIRECT`,因为你人在境外,直连最快。
有个限制脚本会当场告诉你:**只有 CN 有成体系的域名列表**。换成别的国家代码就只能按 IP 匹配,该地区挂境外 CDN 的站点会漏掉。
## 多开入站端口
一个端口对应一个出站——从 7801 进来的全走香港节点,从 7802 进来的全走日本。菜单「配置管理 → 入站端口」增删,写成 mihomo 的 `listeners`:
```yaml
listeners:
- { name: in-7801, type: mixed, listen: 0.0.0.0, port: 7801, proxy: HK }
- { name: in-7802, type: mixed, listen: 0.0.0.0, port: 7802 }
```
用法就是把代理地址指到不同端口:
```bash
curl -x http://127.0.0.1:7801 https://example.com # 走 HK 组
curl -x http://127.0.0.1:7802 https://example.com # 按 rules 分流
```
出站可以选任意一个已有策略组、`DIRECT`,或者「按 rules 分流」。
**选了具体出站就完全跳过 rules**:这个端口的流量一律走它,包括本该直连的内网以外流量。脚本在你选完之后会提醒一次。想保留分流就选「按 rules」。
类型支持 `mixed`(HTTP + SOCKS5 同口)、`http`、`socks`。端口和主代理端口或其它入站冲突会被当场拒绝。
## GEO 数据和规则集
`GEOIP`、`GEOSITE`、`RULE-SET` 规则都要靠数据文件才能匹配。内核缺文件时会自己去下载,但**下载失败是静默的**。
失败的表现是:规则一条都匹配不上,流量全落到最后的 `MATCH`,看起来就像"配置没生效"——你会先去怀疑节点,而不是怀疑一个几天前静默失败的下载。
菜单「配置管理 → GEO / 规则集」把这件事变可见:列出配置目录里的 `geoip.dat`、`geosite.dat`、`*.mmdb` 和 `rules/*.mrs`,带大小和上次更新时间。几天没变就是没更新成功。
「立即更新」优先走管理接口的 `POST /configs/geo`,**不用重启内核**。管理接口没开、或服务没在运行时,回退到删掉数据文件再重启服务重新下载(动手前会问你)。
「自动更新 开/关」写的是 `geo-auto-update` 和 `geo-update-interval: 24`。
## 改配置的安全网
上面这些改动——换订阅、改端口、加入站、开关自动更新——都不用手写 YAML,也都走同一套保护。
每次动手之前自动备份成 `config.yaml.bak-<时间戳>`,改完立刻用内核 `-t` 校验。**校验不过会问你要不要还原**,服务正在运行的话再问一句要不要重启生效。
需要手写的复杂配置(DNS、TUN、自定义规则)请用菜单里的「手动编辑配置」,或者用「从 URL 导入完整配置」整份换掉——这两条路同样有备份、校验、还原兜底。 需要手写的复杂配置(DNS、TUN、自定义规则)请用菜单里的「手动编辑配置」,或者用「从 URL 导入完整配置」整份换掉——这两条路同样有备份、校验、还原兜底。
+456 -81
View File
@@ -17,6 +17,8 @@ SELF_VERSION="2.0.0"
REPO="MetaCubeX/mihomo" REPO="MetaCubeX/mihomo"
UI_ZIP="https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip" UI_ZIP="https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
# 测延迟用的地址:返回 204 空响应,够轻,全球都能连
HEALTH_URL="https://www.gstatic.com/generate_204"
BIN="${MIHOMO_BIN:-/usr/local/bin/mihomo}" BIN="${MIHOMO_BIN:-/usr/local/bin/mihomo}"
CONF_DIR="${MIHOMO_CONF_DIR:-/etc/mihomo}" CONF_DIR="${MIHOMO_CONF_DIR:-/etc/mihomo}"
@@ -524,6 +526,44 @@ cfg_apply() { # cfg_apply <备份路径> — 校验并按
return 1 return 1
} }
# 订阅里的节点名千奇百怪,用一串关键词把常见写法都罩住。(?i) 是忽略大小写。
region_filter() {
case "$1" in
HK) printf '(?i)香港|hk|hong ?kong|🇭🇰' ;;
TW) printf '(?i)台湾|台灣|tw|taiwan|🇹🇼' ;;
JP) printf '(?i)日本|jp|japan|东京|大阪|🇯🇵' ;;
SG) printf '(?i)新加坡|sg|singapore|狮城|獅城|🇸🇬' ;;
US) printf '(?i)美国|美國|us|united ?states|usa|🇺🇸' ;;
KR) printf '(?i)韩国|韓國|kr|korea|首尔|首爾|🇰🇷' ;;
UK|GB) printf '(?i)英国|英國|uk|united ?kingdom|london|🇬🇧' ;;
DE) printf '(?i)德国|德國|de|germany|🇩🇪' ;;
FR) printf '(?i)法国|法國|fr|france|🇫🇷' ;;
*) return 1 ;;
esac
}
REGIONS_SUPPORTED="HK TW JP SG US KR UK DE FR"
cc_normalize() { # 两位国家代码,转大写;不合格式就返回空并说明
local cc; cc="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
if [[ "$cc" =~ ^[A-Z]{2}$ ]]; then
printf '%s' "$cc"
else
warn " «$1» 不是两位国家代码,这条地区规则就先不写了,之后可以手动加"
printf ''
fi
}
# meta-rules-dat 的单个规则集。.mrs 是二进制格式,比整包 geosite.dat 小得多,
# 只下你真正要用的那几个列表。走 gh_url 所以 --mirror 对它同样生效。
mrs_url() { gh_url "MetaCubeX/meta-rules-dat/raw/meta/geo/$1/$2.mrs"; }
cfg_group_names() { # 配置里现有的策略组名(入站选出站时用)
local f; f="$(conf_file)"
[[ -f "$f" ]] || return 0
# 组名后面常跟着行内注释,先切到第一个 # 为止,再去掉尾部空格
sed -n -E 's/^ - name:[[:space:]]*([^#]*).*$/\1/p' "$f" | sed -E 's/[[:space:]]+$//'
}
cfg_wizard() { # 生成一份全新的 config.yaml cfg_wizard() { # 生成一份全新的 config.yaml
local f; f="$(conf_file)" local f; f="$(conf_file)"
mkdir -p "$CONF_DIR" mkdir -p "$CONF_DIR"
@@ -533,21 +573,35 @@ cfg_wizard() { # 生成一份全新的 config.yaml
log "${D} 生成的是一份最小可用配置:一个订阅、一组选择/自动测速分组、内网直连其余走代理。${N}" log "${D} 生成的是一份最小可用配置:一个订阅、一组选择/自动测速分组、内网直连其余走代理。${N}"
log "" log ""
local sub port lan ctrl_mode ctrl secret ui rules_mode local port lan ctrl_mode ctrl secret ui rules_mode
local old_sub old_port old_ctrl old_secret local old_list old_port old_ctrl old_secret
old_sub="$(cfg_sub_url || true)"; old_port="$(cfg_get 'mixed-port' || true)" old_list="$(cfg_sub_list || true)"; old_port="$(cfg_get 'mixed-port' || true)"
old_ctrl="$(cfg_get 'external-controller' || true)"; old_secret="$(cfg_get 'secret' || true)" old_ctrl="$(cfg_get 'external-controller' || true)"; old_secret="$(cfg_get 'secret' || true)"
local tries=0 # ── 订阅:可以填多个 ──
local -a subs=()
if [[ -n "$old_list" ]]; then
log " ${D}现有订阅(回车=沿用,输入新地址=覆盖):${N}"
while IFS=$'\t' read -r _i _n _u; do [[ -n "$_u" ]] && log " $_i. $_u"; done <<<"$old_list"
fi
local n=1 s dflt tries=0
while :; do while :; do
ask_default " 订阅链接(机场给的那个 http(s) 地址)" "${old_sub:-}" sub dflt="$(sed -n "${n}p" <<<"$old_list" | cut -f3)"
[[ -n "$sub" ]] && break if (( n == 1 )); then
if (( ++tries >= 3 )); then ask_default " 订阅 #1(机场给的 http(s) 地址)" "$dflt" s
warn " 一直没拿到订阅链接,向导先退出了,配置没有改动" if [[ -z "$s" ]]; then
return 1 (( ++tries >= 3 )) && { warn " 一直没拿到订阅链接,向导先退出了,配置没有改动"; return 1; }
warn " 至少要有一个订阅,一个节点都没有 mihomo 启动不了"
continue
fi
else
ask_default " 订阅 #${n}(有别的机场就继续加,回车=到此为止)" "$dflt" s
[[ -z "$s" ]] && break
fi fi
warn " 订阅链接不能空,没有节点 mihomo 启动不了" subs+=("$s"); n=$((n + 1))
(( n > 8 )) && { info " 最多 8 个订阅,够用了"; break; }
done done
(( ${#subs[@]} > 1 )) && ok " 共 ${#subs[@]} 个订阅,节点会合并到同一组里"
ask_default " 本地代理端口(HTTP 和 SOCKS5 共用)" "${old_port:-7890}" port ask_default " 本地代理端口(HTTP 和 SOCKS5 共用)" "${old_port:-7890}" port
[[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是: $port" [[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是: $port"
@@ -571,18 +625,40 @@ cfg_wizard() { # 生成一份全新的 config.yaml
ui="no" ui="no"
fi fi
# ── 按地区拆分组 ──
log "" log ""
log " ${D}分流规则。内网地址在两种模式下都是直连。${N}" log " ${D}可以按地区把节点拆成几个组,每组自动挑该地区里延迟最低的那个。${N}"
ask_default " 1=全部走代理 2=指定国家/地区直连,其余走代理" "1" rules_mode log " ${D}支持 ${REGIONS_SUPPORTED},留空=不拆,所有节点放一组。${N}"
local direct_cc="" local regions_in; ask_default " 拆哪些地区(空格分隔)" "" regions_in
if [[ "$rules_mode" == "2" ]]; then local -a rlist=(); local r
ask_default " 哪个国家/地区直连(两位 ISO 代码,如 CN / US / JP / DE)" "CN" direct_cc for r in $regions_in; do
direct_cc="$(printf '%s' "$direct_cc" | tr '[:lower:]' '[:upper:]')" r="$(printf '%s' "$r" | tr '[:lower:]' '[:upper:]')"
if [[ ! "$direct_cc" =~ ^[A-Z]{2}$ ]]; then if region_filter "$r" >/dev/null 2>&1; then
warn " «$direct_cc» 不像两位国家代码,这条直连规则就先不写了,需要的话之后手动加 GEOIP 规则" rlist+=("$r")
direct_cc="" else
warn " 不认识地区代码 «$r»,跳过它(支持: ${REGIONS_SUPPORTED})"
fi fi
fi done
(( ${#rlist[@]} )) && warn " 提醒: 某个地区一个节点都没匹配上的话,内核会拒绝启动。下面的校验会告诉你"
# ── 分流规则 ──
log ""
log " ${D}分流规则。三种模式下内网地址都是直连。${N}"
log " ${C}1${N} 全部走代理"
log " ${C}2${N} 指定地区直连、其余走代理 ${D}(常见的翻墙用法)${N}"
log " ${C}3${N} 指定地区走代理、其余直连 ${D}(回国/分区代理)${N}"
ask_default " 选哪个" "1" rules_mode
local direct_cc="" proxy_cc=""
case "$rules_mode" in
2) ask_default " 哪个地区直连(两位 ISO 代码,如 CN / US / JP / DE)" "CN" direct_cc
direct_cc="$(cc_normalize "$direct_cc")" ;;
3) ask_default " 哪个地区走代理(两位 ISO 代码,回国填 CN)" "CN" proxy_cc
proxy_cc="$(cc_normalize "$proxy_cc")"
if [[ -n "$proxy_cc" && "$proxy_cc" != "CN" ]]; then
warn " 只有 CN 有成体系的域名列表,$proxy_cc 只能按 IP 匹配"
warn " 后果: 该地区挂在境外 CDN 上的站点会漏掉,走直连而不是代理"
fi ;;
esac
# 先写到临时文件,确认无误再落盘 # 先写到临时文件,确认无误再落盘
local tmp; tmp="$(mktemp)" local tmp; tmp="$(mktemp)"
@@ -624,34 +700,53 @@ geox-url:
EOF EOF
fi fi
cat <<EOF # 订阅:每个机场一个 provider,行尾的标记是脚本回头找它的依据
local i use_list=""
printf '\nproxy-providers:\n'
for ((i = 1; i <= ${#subs[@]}; i++)); do
use_list+="${use_list:+, }sub${i}"
printf ' sub%d:\n' "$i"
printf ' type: http\n'
printf ' url: "%s" # mihomo-up:sub:sub%d\n' "${subs[i-1]}" "$i"
printf ' path: ./providers/sub%d.yaml\n' "$i"
printf ' interval: 86400 # 每 24 小时自动拉取一次订阅\n'
printf ' health-check:\n enable: true\n url: %s\n interval: 300\n' "$HEALTH_URL"
done
proxy-providers: # 分组:PROXY 是总开关,AUTO 自动测速,地区组各管一片
subs: local proxies_list="AUTO"
type: http for r in ${rlist[@]+"${rlist[@]}"}; do proxies_list+=", $r"; done
url: "${sub}" # mihomo-up:sub proxies_list+=", DIRECT"
path: ./providers/subs.yaml
interval: 86400 # 每 24 小时自动拉取一次订阅
health-check:
enable: true
url: https://www.gstatic.com/generate_204
interval: 300
proxy-groups: printf '\nproxy-groups:\n'
- name: PROXY # 手动选节点,默认跟着 AUTO 走 printf ' - name: PROXY # 总开关,手动选;默认跟着 AUTO 走\n'
type: select printf ' type: select\n proxies: [%s]\n use: [%s]\n' "$proxies_list" "$use_list"
proxies: [AUTO, DIRECT] printf ' - name: AUTO # 全部节点里自动挑延迟最低的\n'
use: [subs] printf ' type: url-test\n use: [%s]\n url: %s\n interval: 300\n tolerance: 50\n' \
- name: AUTO # 自动挑延迟最低的节点 "$use_list" "$HEALTH_URL"
type: url-test for r in ${rlist[@]+"${rlist[@]}"}; do
use: [subs] printf ' - name: %s # 只看名字里带 %s 关键词的节点\n' "$r" "$r"
url: https://www.gstatic.com/generate_204 printf ' type: url-test\n use: [%s]\n filter: "%s"\n' "$use_list" "$(region_filter "$r")"
interval: 300 printf ' url: %s\n interval: 300\n tolerance: 50\n' "$HEALTH_URL"
tolerance: 50 done
rules: # 回国模式要按域名和 IP 两个维度匹配,用 .mrs 规则集,只下这一两个列表
EOF local cc_l=""
if [[ -n "$proxy_cc" ]]; then
cc_l="$(printf '%s' "$proxy_cc" | tr '[:upper:]' '[:lower:]')"
printf '\n# 规则集:内核按 interval 自动更新;proxy: DIRECT 指下载规则集本身走直连\n'
printf 'rule-providers:\n'
if [[ "$proxy_cc" == "CN" ]]; then
printf ' %s-domain:\n type: http\n behavior: domain\n format: mrs\n' "$cc_l"
printf ' url: "%s"\n path: ./rules/%s-domain.mrs\n interval: 86400\n proxy: DIRECT\n' \
"$(mrs_url geosite "$cc_l")" "$cc_l"
fi
printf ' %s-ip:\n type: http\n behavior: ipcidr\n format: mrs\n' "$cc_l"
printf ' url: "%s"\n path: ./rules/%s-ip.mrs\n interval: 86400\n proxy: DIRECT\n' \
"$(mrs_url geoip "$cc_l")" "$cc_l"
fi
printf '\nrules:\n'
cat <<'EOF' cat <<'EOF'
# 本机和内网地址一律直连,不然会绕出去再回来 # 本机和内网地址一律直连,不然会绕出去再回来
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve - IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
@@ -660,11 +755,19 @@ EOF
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
EOF EOF
if [[ -n "$direct_cc" ]]; then if [[ -n "$proxy_cc" ]]; then
printf ' # 目标 IP 属于 %s 就直连;用到 GEOIP 的话内核首次启动会去下载 GeoIP 数据库\n' "$direct_cc" # 回国:命中的走代理,其余直连。域名规则排在前面,因为挂境外 CDN 的
# 国内站点解析出来的 IP 不在本国段,只靠 GEOIP 会漏。
[[ "$proxy_cc" == "CN" ]] && printf ' - RULE-SET,%s-domain,PROXY\n' "$cc_l"
printf ' - RULE-SET,%s-ip,PROXY,no-resolve\n' "$cc_l"
printf ' - MATCH,DIRECT\n'
elif [[ -n "$direct_cc" ]]; then
printf ' # 目标 IP 属于 %s 就直连。用到 GEOIP,内核首次启动会去下载 GeoIP 数据库\n' "$direct_cc"
printf ' - GEOIP,%s,DIRECT\n' "$direct_cc" printf ' - GEOIP,%s,DIRECT\n' "$direct_cc"
printf ' - MATCH,PROXY\n'
else
printf ' - MATCH,PROXY\n'
fi fi
printf ' - MATCH,PROXY\n'
} > "$tmp" } > "$tmp"
local bak="" local bak=""
@@ -679,31 +782,69 @@ EOF
cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「编辑配置」改,或重新运行一遍向导" cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「编辑配置」改,或重新运行一遍向导"
} }
cfg_sub_url() { # 读回本脚本写的订阅链接 # 订阅行由本脚本写入,行尾带 "# mihomo-up:sub:<provider名>" 标记。
# 认这个标记而不是猜 YAML 结构,所以手写配置里别处的 url: 不会被误伤。
cfg_sub_list() { # 每行输出「序号 <TAB> provider名 <TAB> 订阅地址」
local f; f="$(conf_file)" local f; f="$(conf_file)"
[[ -f "$f" ]] || return 0 [[ -f "$f" ]] || return 0
sed -n -E 's|^[[:space:]]*url:[[:space:]]*"?([^"#]*)"?[[:space:]]*#[[:space:]]*mihomo-up:sub.*|\1|p' "$f" \ sed -n -E 's|^[[:space:]]*url:[[:space:]]*"?([^"#]*)"?[[:space:]]*#[[:space:]]*mihomo-up:sub:?([A-Za-z0-9_-]*).*|\2\t\1|p' "$f" \
| sed -n '1p' | sed -E 's/[[:space:]]+$//' | sed -E 's/\t[[:space:]]*/\t/; s/[[:space:]]+$//' \
| awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, ($1 == "" ? "subs" : $1), $2 }'
} }
cfg_change_sub() { cfg_set_sub() { # cfg_set_sub <序号> <新地址>
local f; f="$(conf_file)" local idx="$1" new="$2" f tmp; f="$(conf_file)"; tmp="$(mktemp)"
[[ -f "$f" ]] || { warn "还没有配置文件,先用「生成配置」运行一遍向导"; return 1; } # 整行重建(缩进 + 新地址 + 原样保留行尾标记),比在原行上做替换更不容易出错
local cur; cur="$(cfg_sub_url)" awk -v idx="$idx" -v url="$new" '
if [[ -z "$cur" ]]; then ($0 ~ /url:/ && $0 ~ /mihomo-up:sub/) {
warn "在 $f 里没找到本脚本写的订阅行(标记是行尾的 # mihomo-up:sub)" n++
warn "这份配置多半是手写或外部导入的,修改订阅请用「编辑配置」,或重新运行向导生成新配置" if (n == idx) {
indent = $0; sub(/[^ ].*$/, "", indent)
tag = $0; sub(/^.*#/, "#", tag)
print indent "url: \"" url "\" " tag
next
}
}
{ print }
' "$f" > "$tmp" && cat "$tmp" > "$f"
rm -f "$tmp"
}
cfg_sub_menu() {
cfg_require || return 1
local f list; f="$(conf_file)"
list="$(cfg_sub_list)"
if [[ -z "$list" ]]; then
warn "在 $f 里没找到本脚本写的订阅行(标记是行尾的 # mihomo-up:sub:...)"
warn "这份配置多半是手写或外部导入的。改订阅请用「手动编辑配置」,或重新运行向导生成一份新的"
return 1 return 1
fi fi
local new; ask_default " 新的订阅链接" "$cur" new
log ""
log " ${D}当前订阅:${N}"
local i nm u
while IFS=$'\t' read -r i nm u; do
[[ -n "$u" ]] && printf ' %s%s.%s %s %s(%s)%s\n' "$C" "$i" "$N" "$u" "$D" "$nm" "$N" >&2
done <<<"$list"
log " ${D}增加或删除订阅要重新生成配置,向导会预先填好现有这几条。${N}"
local total; total="$(grep -c . <<<"$list")"
local pick="1"
(( total > 1 )) && ask_default " 改哪一条(序号)" "1" pick
if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then
warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1
fi
nm="$(sed -n "${pick}p" <<<"$list" | cut -f2)"
local cur; cur="$(sed -n "${pick}p" <<<"$list" | cut -f3)"
local new; ask_default " 第 $pick 条的新地址" "$cur" new
[[ -n "$new" && "$new" != "$cur" ]] || { info "没有改动"; return 0; } [[ -n "$new" && "$new" != "$cur" ]] || { info "没有改动"; return 0; }
local bak; bak="$(cfg_backup)" local bak; bak="$(cfg_backup)"
local esc; esc="$(printf '%s' "$new" | sed -e 's/[\\&|]/\\&/g')" cfg_set_sub "$pick" "$new"
sed -i -E "s|^([[:space:]]*url:[[:space:]]*).*(#[[:space:]]*mihomo-up:sub.*)|\1\"${esc}\" \2|" "$f" ok "第 $pick 条订阅已更新"
ok "订阅链接已更新" # 旧节点缓存留着的话内核会继续用它,删掉才会去拉取新地址
# 订阅缓存留着会继续用旧节点,删掉让内核下次启动重新拉 rm -f "$CONF_DIR/providers/${nm}.yaml" 2>/dev/null || true
rm -f "$CONF_DIR/providers/subs.yaml" 2>/dev/null || true
cfg_apply "$bak" cfg_apply "$bak"
} }
@@ -803,6 +944,234 @@ cfg_backup_menu() {
cfg_apply "" cfg_apply ""
} }
# ───────────────────────── 入站端口 ─────────────────────────
# 一个端口对应一个出站:从 7801 进来的全走香港,从 7802 进来的全走日本。
# 每条入站写成一行 YAML 流式映射并带标记,加/删就是加一行/删一行,不动别处。
LISTENER_TAG="# mihomo-up:listener"
cfg_listener_list() { # 每行输出「序号 <TAB> 整行内容」
local f; f="$(conf_file)"
[[ -f "$f" ]] || return 0
grep -n "$LISTENER_TAG" "$f" 2>/dev/null \
| sed -E 's/^([0-9]+):[[:space:]]*/\1\t/' \
| awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, $1, $2 }'
}
cfg_listener_show() {
local list; list="$(cfg_listener_list)"
if [[ -z "$list" ]]; then
log " ${D}还没有额外的入站端口。主代理端口 $(cfg_get 'mixed-port') 照常走 rules。${N}"
return 0
fi
log " ${D}额外的入站端口:${N}"
local i _ln body
while IFS=$'\t' read -r i _ln body; do
[[ -n "$body" ]] || continue
local nm pt px
nm="$(sed -E 's/.*name:[[:space:]]*([^,}]*).*/\1/' <<<"$body")"
pt="$(sed -E 's/.*port:[[:space:]]*([^,}]*).*/\1/' <<<"$body")"
px="$(grep -o 'proxy:[^,}]*' <<<"$body" | sed -E 's/proxy:[[:space:]]*//' || true)"
printf ' %s%s.%s 端口 %-6s → %s %s(%s)%s\n' \
"$C" "$i" "$N" "$pt" "${px:-按 rules 分流}" "$D" "$nm" "$N" >&2
done <<<"$list"
}
cfg_listener_add() {
cfg_require || return 1
local port name type out
ask_default " 新入站的端口" "7801" port
[[ "$port" =~ ^[0-9]+$ ]] || { warn "端口要是数字,收到的是「$port」"; return 1; }
if grep -q "port: ${port}," "$(conf_file)" 2>/dev/null; then
warn "端口 $port 已经被另一条入站占了"; return 1
fi
[[ "$port" == "$(cfg_get 'mixed-port')" ]] && { warn "端口 $port 就是主代理端口,换一个"; return 1; }
ask_default " 入站类型(mixed=HTTP+SOCKS5 / http / socks)" "mixed" type
case "$type" in mixed|http|socks) ;; *) warn "类型只能是 mixed / http / socks"; return 1 ;; esac
log ""
log " ${D}这个端口的流量走哪个出站:${N}"
local -a groups=(); local g
while IFS= read -r g; do [[ -n "$g" ]] && groups+=("$g"); done < <(cfg_group_names)
local idx=1
printf ' %s0.%s 按 rules 分流 %s(和主端口一样)%s\n' "$C" "$N" "$D" "$N" >&2
for g in ${groups[@]+"${groups[@]}"}; do
printf ' %s%s.%s %s\n' "$C" "$idx" "$N" "$g" >&2; idx=$((idx + 1))
done
printf ' %s%s.%s DIRECT %s(不走代理)%s\n' "$C" "$idx" "$N" "$D" "$N" >&2
local pick; ask_default " 选一个" "0" pick
[[ "$pick" =~ ^[0-9]+$ ]] || { warn "要填序号"; return 1; }
if (( pick == 0 )); then out=""
elif (( pick == idx )); then out="DIRECT"
elif (( pick >= 1 && pick < idx )); then out="${groups[pick-1]}"
else warn "没有这个序号"; return 1; fi
name="in-${port}"
local line=" - { name: ${name}, type: ${type}, listen: 0.0.0.0, port: ${port}"
[[ -n "$out" ]] && line+=", proxy: ${out}"
line+=" } ${LISTENER_TAG}"
local bak; bak="$(cfg_backup)"
local f tmp; f="$(conf_file)"; tmp="$(mktemp)"
if grep -q '^listeners:' "$f"; then
awk -v l="$line" '{ print } /^listeners:[[:space:]]*$/ && !d { print l; d=1 }' "$f" > "$tmp"
else
{ cat "$f"; printf '\nlisteners:\n%s\n' "$line"; } > "$tmp"
fi
cat "$tmp" > "$f"; rm -f "$tmp"
if [[ -n "$out" ]]; then
ok "入站 $port → $out"
warn "注意: 指定了出站就完全跳过 rules,从这个端口进来的流量一律走 $out"
else
ok "入站 $port 已添加,按 rules 分流"
fi
cfg_apply "$bak"
}
cfg_listener_del() {
cfg_require || return 1
local list; list="$(cfg_listener_list)"
[[ -n "$list" ]] || { warn "没有可删除的额外入站端口"; return 1; }
cfg_listener_show
local total; total="$(grep -c . <<<"$list")"
local pick; ask_default " 删除哪一条(序号)" "" pick
if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then
warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1
fi
local bak; bak="$(cfg_backup)"
local f tmp; f="$(conf_file)"; tmp="$(mktemp)"
awk -v idx="$pick" -v tag="$LISTENER_TAG" '
index($0, tag) { n++; if (n == idx) next }
{ print }
' "$f" > "$tmp"
# 删光了就把空的 listeners: 标题也去掉,留着它是个没有值的键
if ! grep -q "$LISTENER_TAG" "$tmp"; then
awk '!/^listeners:[[:space:]]*$/' "$tmp" > "${tmp}.2" && mv "${tmp}.2" "$tmp"
fi
cat "$tmp" > "$f"; rm -f "$tmp"
ok "第 $pick 条入站已删除"
cfg_apply "$bak"
}
cfg_listener_menu() {
while true; do
log ""
log "${W} 入站端口${N} ${D}— 多开几个本地端口,各自绑不同出站${N}"
cfg_listener_show
cat >&2 <<EOF
${C}1.${N} ➕ 添加一个入站端口
${C}2.${N} ➖ 删除一个入站端口
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c
echo >&2
case "$c" in
1) cfg_listener_add || true; pause ;;
2) cfg_listener_del || true; pause ;;
0|q|"") return 0 ;;
*) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;;
esac
done
}
# ───────────────────────── GEO / 规则集 ─────────────────────────
# 这些数据文件决定 GEOIP / GEOSITE / RULE-SET 规则能不能匹配上。
# 内核缺文件时会自己去下载,但下载失败是静默的:规则一条都匹配不上,
# 流量全落到最后的 MATCH,看起来就像"配置没生效"。所以这里给出可见的状态。
ctrl_api() { # ctrl_api <方法> <路径> [数据];管理接口没开就返回 1
local ctrl secret host port
ctrl="$(cfg_get 'external-controller')"; secret="$(cfg_get 'secret')"
[[ -n "$ctrl" ]] || return 1
host="${ctrl%:*}"; port="${ctrl##*:}"
[[ -z "$host" || "$host" == "0.0.0.0" || "$host" == "[::]" ]] && host="127.0.0.1"
local -a args=(-fsS -m 30 -X "$1")
[[ -n "$secret" ]] && args+=(-H "Authorization: Bearer $secret")
[[ -n "${3:-}" ]] && args+=(-H "Content-Type: application/json" -d "$3")
curl "${args[@]}" "http://${host}:${port}$2"
}
geo_show() {
log " ${D}$CONF_DIR 里的数据文件:${N}"
local found=0 p
for p in "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \
"$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb "$CONF_DIR"/rules/*.mrs; do
[[ -f "$p" ]] || continue
found=1
printf ' %-22s %8s %s%s%s\n' "$(basename "$p")" \
"$(du -h "$p" 2>/dev/null | cut -f1)" "$D" "$(date -r "$p" '+%F %T')" "$N" >&2
done
if (( found == 0 )); then
log " ${Y}一个都没有${N}"
log " ${D}配置里没用到 GEOIP/GEOSITE/RULE-SET 的话,这是正常的。${N}"
log " ${D}用到了却没有文件,说明内核还没下载成功,规则会全部匹配不上。${N}"
fi
local au; au="$(cfg_get 'geo-auto-update')"
log " ${D}自动更新 geo-auto-update: ${au:-未设置(默认关)}${N}"
}
geo_refresh() {
cfg_require || return 1
info "让内核重新下载 GEO 数据库…"
if ctrl_api POST /configs/geo '{"path":"","payload":""}' >/dev/null 2>&1; then
ok "已通过管理接口触发更新,不用重启服务"
return 0
fi
if ctrl_api POST /upgrade/geo '{"path":"","payload":""}' >/dev/null 2>&1; then
ok "已通过管理接口触发更新,不用重启服务"
return 0
fi
warn "管理接口没响应(没开启,或者服务没在运行),改用删文件 + 重启的办法"
confirm_default_yes " 删掉现有的 GEO 文件和 .mrs 规则集,然后重启服务重新下载?" || { info "已取消"; return 0; }
rm -f "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \
"$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb 2>/dev/null || true
rm -f "$CONF_DIR"/rules/*.mrs 2>/dev/null || true
ok "已删除,内核下次启动会重新下载"
if [[ "$(svc_state)" == "running" ]]; then
systemctl restart "$SERVICE" && ok "$SERVICE 已重启" || warn "重启失败,看日志: journalctl -u $SERVICE -n 50"
fi
}
geo_toggle_autoupdate() {
cfg_require || return 1
local cur; cur="$(cfg_get 'geo-auto-update')"
local bak; bak="$(cfg_backup)"
if [[ "$cur" == "true" ]]; then
cfg_set 'geo-auto-update' 'false'
ok "已关闭自动更新,GEO 数据库只在缺文件时下载一次"
else
cfg_set 'geo-auto-update' 'true'
[[ -n "$(cfg_get 'geo-update-interval')" ]] || cfg_set 'geo-update-interval' '24'
ok "已打开自动更新,每 $(cfg_get 'geo-update-interval') 小时刷新一次"
fi
cfg_apply "$bak"
}
geo_menu() {
while true; do
log ""
log "${W} GEO / 规则集${N} ${D}— GEOIP、GEOSITE、RULE-SET 规则靠这些数据文件匹配${N}"
geo_show
cat >&2 <<EOF
${C}1.${N} 🔄 立即更新
${C}2.${N} ⏱️ 自动更新 开/关
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c
echo >&2
case "$c" in
1) geo_refresh || true; pause ;;
2) geo_toggle_autoupdate || true; pause ;;
0|q|"") return 0 ;;
*) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;;
esac
done
}
# ───────────────────────── 一键安装 ───────────────────────── # ───────────────────────── 一键安装 ─────────────────────────
do_install() { do_install() {
(( DRY_RUN )) || require_root "安装 mihomo" (( DRY_RUN )) || require_root "安装 mihomo"
@@ -1029,7 +1398,7 @@ ${W} 选个操作吧:${N}
${C}4.${N} 📌 安装指定版本内核 ${C}4.${N} 📌 安装指定版本内核
${C}5.${N} ⏪ 回滚到上一版内核 ${C}5.${N} ⏪ 回滚到上一版内核
${C}6.${N} 📦 查看内核备份 ${C}6.${N} 📦 查看内核备份
${C}7.${N} ⚙️ 配置管理 ${D}(订阅 / 端口 / 面板 / 校验)${N} ${C}7.${N} ⚙️ 配置管理 ${D}(订阅 / 端口 / 入站 / GEO / 面板)${N}
${C}8.${N} 🎛️ 服务控制 ${D}(启动 / 停止 / 日志 / 开机自启)${N} ${C}8.${N} 🎛️ 服务控制 ${D}(启动 / 停止 / 日志 / 开机自启)${N}
${C}9.${N} 🗑️ 卸载 ${C}9.${N} 🗑️ 卸载
${C}0.${N} 👋 退出 ${C}0.${N} 👋 退出
@@ -1066,39 +1435,45 @@ EOF
conf_menu() { conf_menu() {
while true; do while true; do
local sub port lan ctrl local port lan ctrl nsub nlis
sub="$(cfg_sub_url || true)"; port="$(cfg_get 'mixed-port' || true)" port="$(cfg_get 'mixed-port' || true)"
lan="$(cfg_get 'allow-lan' || true)"; ctrl="$(cfg_get 'external-controller' || true)" lan="$(cfg_get 'allow-lan' || true)"; ctrl="$(cfg_get 'external-controller' || true)"
nsub="$(cfg_sub_list 2>/dev/null | grep -c . || true)"
nlis="$(cfg_listener_list 2>/dev/null | grep -c . || true)"
cat >&2 <<EOF cat >&2 <<EOF
${W} 配置管理${N} ${D}$(conf_file)${N} ${W} 配置管理${N} ${D}$(conf_file)${N}
${D}代理端口 ${N}${port:-—} ${D}局域网共享 ${N}${lan:-—} ${D}管理接口 ${N}${ctrl:-未开} ${D}代理端口 ${N}${port:-—} ${D}局域网共享 ${N}${lan:-—} ${D}管理接口 ${N}${ctrl:-未开}
${D}订阅 ${N}${sub:-—} ${D}订阅 ${N}${nsub:-0} 个 ${D}额外入站 ${N}${nlis:-0} 个
${C}1.${N} 🧙 生成配置 ${D}(向导, 会覆盖现有配置)${N} ${C}1.${N} 🧙 生成配置 ${D}(向导, 会覆盖现有配置)${N}
${C}2.${N} 🔗 更换订阅链接 ${C}2.${N} 🔗 订阅管理 ${D}(查看 / 更换地址)${N}
${C}3.${N} 🔌 修改代理端口 ${C}3.${N} 🔌 修改代理端口
${C}4.${N} 📡 局域网共享 开/关 ${C}4.${N} 📡 局域网共享 开/关
${C}5.${N} 🎚️ 管理接口 / 面板密钥 ${C}5.${N} 🎚️ 管理接口 / 面板密钥
${C}6.${N} 📥 从 URL 导入完整配置 ${C}6.${N} 🚪 入站端口 ${D}(多开端口, 各绑各的出站)${N}
${C}7.${N} 🧪 校验配置 ${C}7.${N} 🌍 GEO / 规则集 ${D}(状态 / 更新)${N}
${C}8.${N} 📝 手动编辑配置 ${C}8.${N} 📥 从 URL 导入完整配置
${C}9.${N} 💾 配置备份 / 还原 ${C}9.${N} 🧪 校验配置
${C}10.${N} 📝 手动编辑配置
${C}11.${N} 💾 配置备份 / 还原
${C}0.${N} ↩️ 返回 ${C}0.${N} ↩️ 返回
EOF EOF
local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c
echo >&2 echo >&2
case "$c" in case "$c" in
1) cfg_wizard || true; pause ;; 1) cfg_wizard || true; pause ;;
2) cfg_change_sub || true; pause ;; 2) cfg_sub_menu || true; pause ;;
3) cfg_change_port || true; pause ;; 3) cfg_change_port || true; pause ;;
4) cfg_toggle_lan || true; pause ;; 4) cfg_toggle_lan || true; pause ;;
5) cfg_change_ctrl || true; pause ;; 5) cfg_change_ctrl || true; pause ;;
6) cfg_import_url || true; pause ;; 6) cfg_listener_menu ;;
7) cfg_validate || true; pause ;; 7) geo_menu ;;
8) cfg_edit || true; pause ;; 8) cfg_import_url || true; pause ;;
9) cfg_backup_menu || true; pause ;; 9) cfg_validate || true; pause ;;
10) cfg_edit || true; pause ;;
11) cfg_backup_menu || true; pause ;;
0|q|"") return 0 ;; 0|q|"") return 0 ;;
*) warn "没有「$c」这个选项,输入上面列出的数字,或 0 退出/返回"; sleep 1 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 退出/返回"; sleep 1 ;;
esac esac