diff --git a/.env b/.env index cbed358..0b5835d 100644 --- a/.env +++ b/.env @@ -11,6 +11,8 @@ export CLASH_CONTROLLER_SECRET="" export MIXED_PORT="7890" export EXTERNAL_CONTROLLER="0.0.0.0:9090" export CLASH_DNS_PORT="1053" +# 保留订阅自身的 dns.listen。设为 true 后不会用 CLASH_DNS_PORT 覆盖,也不会自动改写该端口;请确保端口可用。 +export CLASH_PRESERVE_DNS_LISTEN="false" # 订阅更新策略 export CLASH_AUTO_UPDATE_SUBSCRIPTIONS="true" diff --git a/README.md b/README.md index 0014a34..ac32eac 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ OpenWrt 下 root/system 安装会把 `clash`、`clashon`、`clashoff` 等命令 ### 多订阅管理 +订阅地址包含 `&` 等 Shell 特殊字符时,请用引号包裹完整地址;也可以直接运行 `clash add`,按提示交互式粘贴地址。例如:`clash add 'https://example.com/sub?target=clash&emoji=true' west`。 + ``` clash add <订阅链接> <名称> clash use @@ -337,6 +339,8 @@ Control 层负责把常用动作收口成可理解的命令和反馈。 KERNEL_TYPE=mihomo MIXED_PORT=7890 EXTERNAL_CONTROLLER=0.0.0.0:9090 +CLASH_DNS_PORT=1053 +CLASH_PRESERVE_DNS_LISTEN=false CLASH_CONTROLLER_SECRET=your-secret CLASH_SUBSCRIPTION_URL=https://example.com/sub MIHOMO_VERSION=v1.19.23 @@ -355,6 +359,7 @@ CLASH_IPV6=auto 按需设置即可,不需要每项都写。 - `CLASH_SHELL_AUTO_RESTORE_PROXY`:控制登录 Shell 是否自动恢复上次 `clashon` 写入的代理变量。默认 `true` 保持兼容;如果不希望 SSH 远程登录后自动带上 `http_proxy` / `https_proxy`,设为 `false`,之后仍可手动执行 `clashon`。 +- `CLASH_PRESERVE_DNS_LISTEN`:设为 `true` 时保留活动订阅中的 `.dns.listen`,不再用 `CLASH_DNS_PORT` 覆盖,也不会因端口占用自动换成其他 DNS 端口;如果订阅没有有效的 `.dns.listen`,仍回退到 `CLASH_DNS_PORT`。请先确认保留的端口未被其他服务占用。 - `CLASH_PREDOWNLOAD_GEO`:控制安装期是否预下载 GEO 数据。默认 `true`,会提前下载 `Country.mmdb`、`geoip.metadb`、`GeoIP.dat`、`GeoSite.dat` 等规则分流常用资源;临时部署、只想先跑起来时可设为 `false` 跳过安装期预下载。注意:当最终运行配置实际使用 `GEOIP` 规则时,启动前仍会按需准备 `Country.mmdb`,否则 Mihomo 无法可靠加载该配置。 - `CLASH_IPV6`:可设为 `true`、`false` 或 `auto`。`true` 同时启用内核 IPv6 与 DNS AAAA,适用于 IPv6-only 节点;`auto` 保留订阅中的 IPv6 设置。 - `CLASH_SUBSCRIPTION_UA`:覆盖拉取订阅时的 User-Agent。默认使用 `clash-verge/v2.4.0`,以便订阅服务返回 Mihomo 支持的 Hysteria2、AnyTLS 等现代协议节点;如果服务商有专用 UA 要求,可在此显式设置。 diff --git a/scripts/core/clashctl.sh b/scripts/core/clashctl.sh index 93a0c7f..b48bf27 100644 --- a/scripts/core/clashctl.sh +++ b/scripts/core/clashctl.sh @@ -152,7 +152,10 @@ ensure_runtime_ports_ready() { info "检测到 external-controller 冲突:${controller_port} -> ${new_controller_port}" fi - if [ -n "${dns_port:-}" ] && [ "$dns_port" != "null" ] && is_port_in_use "$dns_port"; then + if [ "$(config_preserve_dns_listen)" != "true" ] \ + && [ -n "${dns_port:-}" ] \ + && [ "$dns_port" != "null" ] \ + && is_port_in_use "$dns_port"; then new_dns_port="$(resolve_free_port 1053 1999)" write_env_value "CLASH_DNS_PORT" "$new_dns_port" write_runtime_value "INSTALL_PLAN_DNS_PORT" "$new_dns_port" diff --git a/scripts/core/config.sh b/scripts/core/config.sh index d9f9804..e43acf7 100644 --- a/scripts/core/config.sh +++ b/scripts/core/config.sh @@ -276,6 +276,10 @@ config_bool_env_value() { esac } +config_preserve_dns_listen() { + config_bool_env_value "CLASH_PRESERVE_DNS_LISTEN" "false" +} + config_ipv6_mode() { local value @@ -842,7 +846,7 @@ normalize_runtime_config() { local mixed_port controller tun_enable_value tun_stack_value dns_port_value controller_secret_value local tun_auto_route_value tun_auto_redirect_value tun_strict_route_value tun_dns_hijack_value local dashboard_dir_value dashboard_url_value allow_lan_value ipv6_mode_value - local ipv6_value dns_ipv6_value + local ipv6_value dns_ipv6_value dns_listen_value local resolved_ports err_file output [ -s "$file" ] || die "待规范化的配置文件不存在:$file" @@ -859,6 +863,11 @@ normalize_runtime_config() { tun_strict_route_value="$(tun_strict_route)" tun_dns_hijack_value="$(tun_dns_hijack)" dns_port_value="$CLASH_DNS_PORT_RESOLVED" + dns_listen_value="0.0.0.0:${dns_port_value}" + if [ "$(config_preserve_dns_listen)" = "true" ]; then + dns_listen_value="$(config_dns_listen_value "$file" 2>/dev/null || true)" + [ -n "${dns_listen_value:-}" ] || dns_listen_value="0.0.0.0:${dns_port_value}" + fi controller_secret_value="$(ensure_controller_secret)" dashboard_dir_value="$(runtime_dashboard_dir)" dashboard_url_value="$DEFAULT_DASHBOARD_UI_URL" @@ -893,7 +902,7 @@ normalize_runtime_config() { controller_secret_value="$controller_secret_value" \ dashboard_dir_value="$dashboard_dir_value" \ dashboard_url_value="$dashboard_url_value" \ - dns_listen_value="0.0.0.0:${dns_port_value}" \ + dns_listen_value="$dns_listen_value" \ "$(yq_bin)" eval -i ' .["mixed-port"] = (env(mixed_port) | tonumber) | del(.port, .["socks-port"], .["redir-port"], .["tproxy-port"]) | @@ -1898,6 +1907,20 @@ is_valid_port_number() { [ "$port" -ge 1 ] && [ "$port" -le 65535 ] } +config_dns_listen_value() { + local file="${1:-}" + local listen port + + [ -s "$file" ] || return 1 + listen="$("$(yq_bin)" eval '.dns.listen // ""' "$file" 2>/dev/null | head -n 1)" + [ -n "${listen:-}" ] || return 1 + [ "$listen" != "null" ] || return 1 + + port="${listen##*:}" + is_valid_port_number "$port" || return 1 + printf '%s\n' "$listen" +} + csv_has_value() { local csv="$1" local value="$2" @@ -2099,7 +2122,7 @@ load_resolved_runtime_ports() { resolve_runtime_ports() { # Optional: path to a config file to read port from when MIXED_PORT is not set local _hint_config_file="${1:-}" - local preferred_mixed preferred_controller preferred_dns + local preferred_mixed preferred_controller preferred_dns preserved_dns_listen local controller_host preferred_controller_port local mixed_port controller_port dns_port local used_ports="" @@ -2116,6 +2139,16 @@ resolve_runtime_ports() { preferred_mixed="${preferred_mixed:-7890}" preferred_controller="${EXTERNAL_CONTROLLER:-0.0.0.0:9090}" preferred_dns="${CLASH_DNS_PORT:-1053}" + preserved_dns_listen="" + + if [ "$(config_preserve_dns_listen)" = "true" ] \ + && [ -n "${_hint_config_file:-}" ] \ + && [ -s "${_hint_config_file:-}" ]; then + preserved_dns_listen="$(config_dns_listen_value "$_hint_config_file" 2>/dev/null || true)" + if [ -n "${preserved_dns_listen:-}" ]; then + preferred_dns="${preserved_dns_listen##*:}" + fi + fi is_valid_port_number "$preferred_mixed" || die "MIXED_PORT 不合法:$preferred_mixed" is_valid_port_number "$preferred_dns" || die "CLASH_DNS_PORT 不合法:$preferred_dns" @@ -2131,7 +2164,19 @@ resolve_runtime_ports() { is_valid_port_number "$controller_port" || die "invalid external-controller resolution: $controller_port" used_ports="$(csv_append_value "$used_ports" "$controller_port")" - dns_port="$(resolve_runtime_port "$preferred_dns" 1053 1199 "$used_ports" "dns.listen")" + if [ -n "${preserved_dns_listen:-}" ]; then + if csv_has_value "$used_ports" "$preferred_dns"; then + die "订阅保留的 DNS 端口与其他运行端口冲突:$preferred_dns" + fi + + if is_port_in_use "$preferred_dns" && ! port_reserved_by_current_runtime "$preferred_dns"; then + die "订阅保留的 DNS 端口已被占用:$preferred_dns" + fi + + dns_port="$preferred_dns" + else + dns_port="$(resolve_runtime_port "$preferred_dns" 1053 1199 "$used_ports" "dns.listen")" + fi is_valid_port_number "$dns_port" || die "invalid dns.listen resolution: $dns_port" used_ports="$(csv_append_value "$used_ports" "$dns_port")" diff --git a/scripts/dev/check-runtime-config-normalization.sh b/scripts/dev/check-runtime-config-normalization.sh index 56c3818..0a76d2e 100755 --- a/scripts/dev/check-runtime-config-normalization.sh +++ b/scripts/dev/check-runtime-config-normalization.sh @@ -68,6 +68,39 @@ source "$PROJECT_DIR/scripts/core/config.sh" # Keep this regression test independent from the developer's project .env. read_env_value() { return 1; } +# Keep port resolution deterministic for this test; the preservation path must +# not depend on whatever happens to be listening on the developer's machine. +is_port_in_use() { return 1; } +port_reserved_by_current_runtime() { return 1; } + +preserved_port_config="$tmp_dir/preserved-dns-port-config.yaml" +cat > "$preserved_port_config" <<'YAML' +mixed-port: 7890 +external-controller: 0.0.0.0:9090 +dns: + listen: 127.0.0.1:7874 +proxies: [] +proxy-groups: [] +rules: [] +YAML + +unset MIXED_PORT EXTERNAL_CONTROLLER CLASH_DNS_PORT CLASH_PRESERVE_DNS_LISTEN +default_port_resolution="$(resolve_runtime_ports "$preserved_port_config")" +if printf '%s\n' "$default_port_resolution" | grep -qx 'CLASH_DNS_PORT_RESOLVED=1053'; then + echo "ok - default DNS port remains configurable" +else + echo "not ok - default DNS port resolution changed unexpectedly: $default_port_resolution" >&2 + exit 1 +fi + +preserved_port_resolution="$(CLASH_PRESERVE_DNS_LISTEN=true resolve_runtime_ports "$preserved_port_config")" +if printf '%s\n' "$preserved_port_resolution" | grep -qx 'CLASH_DNS_PORT_RESOLVED=7874'; then + echo "ok - preserves subscription DNS port during resolution" +else + echo "not ok - subscription DNS port was not preserved: $preserved_port_resolution" >&2 + exit 1 +fi + resolve_runtime_ports() { printf 'MIXED_PORT_RESOLVED=7891\n' printf 'EXTERNAL_CONTROLLER_RESOLVED=0.0.0.0:9090\n' @@ -96,6 +129,7 @@ secret: old-secret allow-lan: false ipv6: true dns: + listen: 127.0.0.1:7874 ipv6: true proxies: - name: hy2-ipv6 @@ -113,6 +147,9 @@ proxy-groups: [] rules: [] YAML +preserved_normalization_config="$tmp_dir/preserved-dns-normalization.yaml" +cp "$sample_config" "$preserved_normalization_config" + normalize_runtime_config "$sample_config" assert_file_yq() { @@ -143,11 +180,16 @@ assert_yq "removes legacy tproxy-port" 'has("tproxy-port")' "false" assert_yq "keeps controller normalization" '.["external-controller"]' "0.0.0.0:9090" assert_yq "preserves subscription IPv6" '.ipv6' "true" assert_yq "preserves subscription DNS IPv6" '.dns.ipv6' "true" +assert_yq "normalizes DNS listen by default" '.dns.listen' "0.0.0.0:1053" assert_yq "preserves Hysteria2 proxy type" '.proxies[0].type' "hysteria2" assert_yq "preserves IPv6-only proxy server" '.proxies[0].server' "2001:db8::1" assert_yq "preserves AnyTLS proxy type" '.proxies[1].type' "anytls" assert_yq "preserves AnyTLS SNI" '.proxies[1].sni' "example.com" +CLASH_PRESERVE_DNS_LISTEN=true normalize_runtime_config "$preserved_normalization_config" +assert_file_yq "preserves subscription DNS listen when enabled" "$preserved_normalization_config" '.dns.listen' "127.0.0.1:7874" +unset CLASH_PRESERVE_DNS_LISTEN + CLASH_IPV6=false normalize_runtime_config "$sample_config" assert_yq "IPv6 off disables kernel IPv6" '.ipv6' "false" assert_yq "IPv6 off disables DNS IPv6" '.dns.ipv6' "false"