From e4d03f40e098ccf3351ddb72bd1418b8a893194a Mon Sep 17 00:00:00 2001 From: Zhengyi Dong Date: Sun, 19 Jul 2026 20:37:48 +0800 Subject: [PATCH] feat: sync provider-scoped DoH --- README.md | 1 + README_CN.md | 1 + public/task_chs.list | 2 +- public/task_en.list | 2 +- scripts/libs/provider_dns.sh | 545 ++++++++++++++++++++++++++ scripts/menus/task_cmd.sh | 3 + scripts/start.sh | 2 +- scripts/starts/afstart.sh | 3 + scripts/starts/bfstart.sh | 4 + scripts/starts/clash_modify.sh | 4 + tests/fixtures/provider_block.yaml | 11 + tests/fixtures/provider_conflict.yaml | 5 + tests/fixtures/provider_inline.yaml | 5 + tests/provider_dns_test.sh | 140 +++++++ 14 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 scripts/libs/provider_dns.sh create mode 100644 tests/fixtures/provider_block.yaml create mode 100644 tests/fixtures/provider_conflict.yaml create mode 100644 tests/fixtures/provider_inline.yaml create mode 100644 tests/provider_dns_test.sh diff --git a/README.md b/README.md index 2d0973cfc..a3cf802ba 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - **Multi-Kernel Support**: Easily manage and switch between **mihomo** and **sing-box** kernels directly within the Shell environment. - **Flexible Configuration Management**: Supports online import of subscription links and configuration files to simplify the setup process. - **Automated Tasks**: Configure scheduled tasks for automatic updates of configuration files and rules. +- **Provider DNS Sync**: Mihomo proxy providers can resolve their own proxy hostnames with subscription-supplied DoH and refresh the results after provider updates. - **Graphical Dashboard**: Support for online installation and use of local Web Dashboards to intuitively manage built-in rules and traffic. - **Multiple Operation Modes**: Supports switching between various traffic forwarding modes, including Router mode and Local mode. - **One-Click Maintenance**: Built-in online update functionality to keep the script and features up to date. diff --git a/README_CN.md b/README_CN.md index c0343630c..12fb17386 100644 --- a/README_CN.md +++ b/README_CN.md @@ -24,6 +24,7 @@ - **多内核支持**:在 Shell 环境下便捷管理及切换 **mihomo** 与 **sing-box** 内核。 - **灵活配置管理**:支持在线导入订阅连结及配置文件,简化配置流程。 - **自动化任务**:支持配置定时任务,实现配置文件与规则的自动定时更新。 +- **提供者 DNS 同步**:mihomo 节点提供者可使用订阅内的 DoH 解析各自的节点域名,并在提供者更新后自动刷新。 - **图形化面板**:支持在线安装并使用本地 Web 面板(Dashboard),直观管理内置规则与流量。 - **多模式运行**:支持路由模式、本机模式等多种流量转发模式切换。 - **一键维护**:内置脚本在线更新功能,保持版本与功能的及时更迭。 diff --git a/public/task_chs.list b/public/task_chs.list index 40f5ad2b4..5dce2707a 100644 --- a/public/task_chs.list +++ b/public/task_chs.list @@ -7,6 +7,7 @@ 105#$CRASHDIR/task/task.sh hotupdate#热更新在线订阅(不重启) 106#$CRASHDIR/task/task.sh web_save_auto#自动保存面板配置 107#$CRASHDIR/task/task.sh ntp#自动同步ntp时间 +108#$CRASHDIR/task/task.sh sync_provider_dns#同步节点提供者DNS 111#$CRASHDIR/task/task.sh update_core#自动更新内核 112#$CRASHDIR/task/task.sh update_scripts#自动更新脚本 @@ -15,4 +16,3 @@ 121#$CRASHDIR/task/task.sh reset_firewall#重设透明路由防火墙 122#sleep 70 && touch /etc/banner && reboot#重启路由设备-慎用 - diff --git a/public/task_en.list b/public/task_en.list index c9ba3429a..9c9b731a4 100644 --- a/public/task_en.list +++ b/public/task_en.list @@ -7,6 +7,7 @@ 105#$CRASHDIR/task/task.sh hotupdate#Hot update online subscriptions (without restart) 106#$CRASHDIR/task/task.sh web_save_auto#Automatically save panel configuration 107#$CRASHDIR/task/task.sh ntp#Automatically sync NTP time +108#$CRASHDIR/task/task.sh sync_provider_dns#Sync proxy provider DNS 111#$CRASHDIR/task/task.sh update_core#Automatically update core 112#$CRASHDIR/task/task.sh update_scripts#Automatically update scripts @@ -16,4 +17,3 @@ 122#sleep 70 && touch /etc/banner && reboot#Reboot router device – use with caution - diff --git a/scripts/libs/provider_dns.sh b/scripts/libs/provider_dns.sh new file mode 100644 index 000000000..ee6508815 --- /dev/null +++ b/scripts/libs/provider_dns.sh @@ -0,0 +1,545 @@ +#!/bin/sh +# Copyright (C) Juewuy + +[ -n "${__IS_PROVIDER_DNS:-}" ] && return +__IS_PROVIDER_DNS=1 + +provider_dns_init() { + [ -z "${TMPDIR:-}" ] && TMPDIR=/tmp/ShellCrash + [ -z "${BINDIR:-}" ] && BINDIR="$TMPDIR" + [ -z "${TASKCFGDIR:-}" ] && TASKCFGDIR="$CRASHDIR/configs/task" + PROVIDER_DNS_DIR="$CRASHDIR/configs/provider_dns" + PROVIDER_DNS_STATE_DIR="$PROVIDER_DNS_DIR/providers" + PROVIDER_DNS_HOSTS="$PROVIDER_DNS_DIR/hosts.yaml" + PROVIDER_DNS_POLICY="$PROVIDER_DNS_DIR/policy.yaml" +} + +provider_dns_log() { + command logger -t shellcrash-provider-dns "$*" 2>/dev/null || true +} + +provider_dns_enable() { + provider_dns_init + mkdir -p "$TASKCFGDIR" || return 1 + task_file="$TASKCFGDIR/running" + task_line="*/10 * * * * $CRASHDIR/task/task.sh sync_provider_dns ShellCrash-provider-dns" + sed '/ShellCrash-provider-dns/d' "$task_file" 2>/dev/null >"$task_file.tmp" || true + echo "$task_line" >>"$task_file.tmp" + mv -f "$task_file.tmp" "$task_file" +} + +provider_dns_extract_doh() { + provider_file=$1 + awk ' + /^dns:[[:space:]]*/ { in_dns = 1 } + in_dns && NR != 1 && /^[A-Za-z0-9_-]+:[[:space:]]*/ && $0 !~ /^dns:/ { exit } + in_dns { print } + ' "$provider_file" | + grep -oE "https://[^][,}\"'[:space:]]+" 2>/dev/null | + sed 's/[[:space:]]*$//' | + awk '!seen[$0]++' +} + +provider_dns_extract_proxy_doh() { + provider_file=$1 + awk ' + function indentation(value) { + match(value, /^[[:space:]]*/) + return RLENGTH + } + + /^[[:space:]]*proxy-server-nameserver:[[:space:]]*/ { + capture = 1 + base_indent = indentation($0) + print + next + } + capture { + current_indent = indentation($0) + if ($0 ~ /^[[:space:]]*[A-Za-z0-9_-]+:[[:space:]]*/ && current_indent <= base_indent) exit + print + } + ' "$provider_file" | + grep -oE "https://[^][,}\"'[:space:]]+" 2>/dev/null | + awk '!seen[$0]++' +} + +provider_dns_extract_policy_doh() { + provider_file=$1 + domain=$2 + awk -v domain="$domain" ' + function indentation(value) { + match(value, /^[[:space:]]*/) + return RLENGTH + } + + function trim_key(value) { + sub(/^[[:space:]"\047,{]+/, "", value) + sub(/[[:space:]"\047:}]+$/, "", value) + return value + } + + function matches(key, name, suffix) { + key = trim_key(key) + if (key == name) return 1 + if (key ~ /^(\+\.|\*\.)/) { + suffix = substr(key, 3) + return name == suffix || (length(name) > length(suffix) && substr(name, length(name) - length(suffix), 1) == "." && substr(name, length(name) - length(suffix) + 1) == suffix) + } + return 0 + } + + /^[[:space:]]*nameserver-policy:[[:space:]]*/ { + policy_indent = indentation($0) + line = $0 + if (line ~ /\{/) { + sub(/^[^{]*\{/, "", line) + while (match(line, /:[[:space:]]*\[/)) { + key = substr(line, 1, RSTART - 1) + value = substr(line, RSTART) + end = index(value, "]") + if (!end) break + if (matches(key, domain)) print substr(value, 1, end) + line = substr(value, end + 1) + } + exit + } + in_policy = 1 + next + } + in_policy { + current_indent = indentation($0) + if ($0 ~ /^[[:space:]]*[A-Za-z0-9_-]+:[[:space:]]*/ && current_indent <= policy_indent) exit + if ($0 ~ /^[[:space:]]*["\047+*A-Za-z0-9.-]+["\047]*:[[:space:]]*/) { + key = $0 + sub(/:.*/, ":", key) + capture = matches(key, domain) + } + if (capture) print + } + ' "$provider_file" | + grep -oE "https://[^][,}\"'[:space:]]+" 2>/dev/null | + awk '!seen[$0]++' +} + +provider_dns_extract_doh_for_server() { + provider_file=$1 + domain=$2 + urls=$(provider_dns_extract_policy_doh "$provider_file" "$domain") + [ -n "$urls" ] || urls=$(provider_dns_extract_proxy_doh "$provider_file") + [ -n "$urls" ] || urls=$(provider_dns_extract_doh "$provider_file") + printf '%s\n' "$urls" +} + +provider_dns_extract_servers() { + provider_file=$1 + awk ' + function trim(value) { + sub(/^[[:space:]"\047]+/, "", value) + sub(/[[:space:]"\047,}#]+$/, "", value) + return value + } + + /^proxies:[[:space:]]*$/ { in_proxies = 1; next } + in_proxies && /^[A-Za-z0-9_-]+:[[:space:]]*/ { exit } + in_proxies { + line = $0 + while (match(line, /(^|[, {])server:[[:space:]]*/)) { + line = substr(line, RSTART + RLENGTH) + if (substr(line, 1, 1) == "\"") { + line = substr(line, 2) + end = index(line, "\"") + value = end ? substr(line, 1, end - 1) : line + } else if (substr(line, 1, 1) == "\047") { + line = substr(line, 2) + end = index(line, "\047") + value = end ? substr(line, 1, end - 1) : line + } else { + value = line + sub(/[,}#[:space:]].*$/, "", value) + } + value = trim(value) + if (value ~ /^[A-Za-z0-9][-A-Za-z0-9.]*[A-Za-z0-9]$/ && value ~ /\./ && value !~ /^[0-9.]+$/ && !seen[value]++) { + print value + } + if (!end) break + line = substr(line, end + 1) + end = 0 + } + } + ' "$provider_file" +} + +provider_dns_make_query() { + domain=$1 + output=$2 + { + printf '\000\000\001\000\000\001\000\000\000\000\000\000' + old_ifs=$IFS + IFS=. + set -- $domain + IFS=$old_ifs + for label in "$@"; do + length=${#label} + [ "$length" -le 63 ] || return 1 + octal=$(printf '%03o' "$length") + printf "\\$octal%s" "$label" + done + printf '\000\000\001\000\001' + } >"$output" +} + +provider_dns_parse_a() { + response=$1 + if command -v hexdump >/dev/null 2>&1; then + hexdump -v -e '1/1 "%u "' "$response" 2>/dev/null + else + od -An -v -t u1 "$response" 2>/dev/null + fi | + awk ' + { for (i = 1; i <= NF; i++) bytes[++count] = $i } + + function u16(offset) { + return bytes[offset] * 256 + bytes[offset + 1] + } + + function skip_name( label_len) { + while (position <= count) { + label_len = bytes[position] + if (label_len == 0) { + position++ + return + } + if (label_len >= 192) { + position += 2 + return + } + position += label_len + 1 + } + } + + END { + if (count < 12 || bytes[4] % 16 != 0) exit 1 + questions = u16(5) + answers = u16(7) + position = 13 + + for (i = 0; i < questions; i++) { + skip_name() + position += 4 + } + + for (i = 0; i < answers; i++) { + skip_name() + if (position + 9 > count) exit 1 + type = u16(position) + rdlength = u16(position + 8) + position += 10 + if (type == 1 && rdlength == 4 && position + 3 <= count) { + printf "%d.%d.%d.%d\n", bytes[position], bytes[position + 1], bytes[position + 2], bytes[position + 3] + exit 0 + } + position += rdlength + } + exit 1 + } + ' +} + +provider_dns_resolve() { + domain=$1 + urls=$2 + query=$3/query.bin + response=$3/response.bin + + provider_dns_make_query "$domain" "$query" || return 1 + encoded=$(base64 <"$query" 2>/dev/null | tr -d '\r\n=' | tr '+/' '-_') + [ -n "$encoded" ] || return 1 + + case $- in + *f*) restore_glob= ;; + *) set -f; restore_glob=1 ;; + esac + for base_url in $urls; do + case "$base_url" in + *\?*) request_url="${base_url}&dns=${encoded}" ;; + *) request_url="${base_url}?dns=${encoded}" ;; + esac + if [ "${skip_cert:-}" != OFF ]; then + insecure=-k + else + insecure= + fi + if curl $insecure -fsS --connect-timeout 5 --max-time 12 \ + -H 'accept: application/dns-message' "$request_url" >"$response" 2>/dev/null; then + address=$(provider_dns_parse_a "$response" || true) + if [ -n "$address" ]; then + printf '%s\n' "$address" + [ -n "$restore_glob" ] && set +f + return 0 + fi + fi + done + [ -n "$restore_glob" ] && set +f + return 1 +} + +provider_dns_state_name() { + safe_name=$(printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g') + checksum=$(printf '%s' "$1" | cksum 2>/dev/null | awk '{print $1}') + [ -n "$checksum" ] && printf '%s_%s' "$safe_name" "$checksum" || printf '%s' "$safe_name" +} + +provider_dns_find_cache() { + tag=$1 + for provider_file in \ + "$BINDIR/providers/$tag.yaml" \ + "$TMPDIR/providers/$tag.yaml" \ + "$CRASHDIR/providers/$tag.yaml"; do + if [ -s "$provider_file" ]; then + printf '%s\n' "$provider_file" + return 0 + fi + done + return 1 +} + +provider_dns_build_provider() { + tag=$1 + provider_file=$2 + state_name=$3 + work_dir=$4 + + servers=$(provider_dns_extract_servers "$provider_file") + [ -n "$servers" ] || return 1 + + provider_hosts="$work_dir/$state_name.hosts" + provider_policy="$work_dir/$state_name.policy" + : >"$provider_hosts" + : >"$provider_policy" + + for server in $servers; do + urls=$(provider_dns_extract_doh_for_server "$provider_file" "$server") + [ -n "$urls" ] || return 1 + address=$(provider_dns_resolve "$server" "$urls" "$work_dir" || true) + if [ -z "$address" ]; then + provider_dns_log "$tag: failed to resolve a proxy server; keeping the last valid state" + return 1 + fi + printf " '%s': %s\n" "$server" "$address" >>"$provider_hosts" + printf " '%s':\n" "$server" >>"$provider_policy" + printf '%s\n' "$urls" | awk '!seen[$0]++ { printf " - \047%s\047\n", $0 }' >>"$provider_policy" + done + + mv -f "$provider_hosts" "$PROVIDER_DNS_STATE_DIR/$state_name.hosts" + mv -f "$provider_policy" "$PROVIDER_DNS_STATE_DIR/$state_name.policy" + chmod 600 "$PROVIDER_DNS_STATE_DIR/$state_name.hosts" "$PROVIDER_DNS_STATE_DIR/$state_name.policy" 2>/dev/null || true + return 0 +} + +provider_dns_install_aggregate() { + source_file=$1 + target_file=$2 + if [ -f "$target_file" ] && cmp -s "$source_file" "$target_file"; then + return 1 + fi + mv -f "$source_file" "$target_file" + chmod 600 "$target_file" 2>/dev/null || true + return 0 +} + +provider_dns_sync() ( + provider_dns_init + mkdir -p "$PROVIDER_DNS_STATE_DIR" "$TMPDIR" || return 1 + + lock_dir="$TMPDIR/provider_dns.lock" + if ! mkdir "$lock_dir" 2>/dev/null; then + return 0 + fi + work_dir="$TMPDIR/provider_dns.$$" + trap 'rm -rf "$work_dir" "$lock_dir"' EXIT INT TERM + mkdir -p "$work_dir" || return 1 + + current_states="$work_dir/current_states" + : >"$current_states" + while IFS= read -r provider_line; do + case "$provider_line" in + '' | \#*) continue ;; + esac + tag=${provider_line%% *} + [ -n "$tag" ] || continue + case "$tag" in + */* | *..*) continue ;; + esac + state_name=$(provider_dns_state_name "$tag") + echo "$state_name" >>"$current_states" + provider_file=$(provider_dns_find_cache "$tag" || true) + if [ -n "$provider_file" ]; then + provider_dns_build_provider "$tag" "$provider_file" "$state_name" "$work_dir" || true + fi + done <"$CRASHDIR/configs/providers.cfg" 2>/dev/null + + aggregate_hosts="$work_dir/hosts.yaml" + aggregate_policy="$work_dir/policy.yaml" + : >"$aggregate_hosts" + : >"$aggregate_policy" + while IFS= read -r state_name; do + [ -s "$PROVIDER_DNS_STATE_DIR/$state_name.hosts" ] && cat "$PROVIDER_DNS_STATE_DIR/$state_name.hosts" >>"$aggregate_hosts" + [ -s "$PROVIDER_DNS_STATE_DIR/$state_name.policy" ] && cat "$PROVIDER_DNS_STATE_DIR/$state_name.policy" >>"$aggregate_policy" + done <"$current_states" + awk '!seen[$1]++' "$aggregate_hosts" >"$aggregate_hosts.unique" + awk ' + /^ .*:$/ { + keep = !seen[$0]++ + if (keep) print + next + } + keep { print } + ' "$aggregate_policy" >"$aggregate_policy.unique" + + changed=0 + provider_dns_install_aggregate "$aggregate_hosts.unique" "$PROVIDER_DNS_HOSTS" && changed=1 + provider_dns_install_aggregate "$aggregate_policy.unique" "$PROVIDER_DNS_POLICY" && changed=1 + if [ "$changed" = 1 ] && [ "${PROVIDER_DNS_NO_RELOAD:-}" != 1 ]; then + provider_dns_reload || return 1 + provider_dns_log 'provider DNS state updated' + fi + return 0 +) + +provider_dns_apply_config() ( + provider_dns_init + config_file=$1 + [ -f "$config_file" ] || return 1 + policy_file=$PROVIDER_DNS_POLICY + hosts_file=$PROVIDER_DNS_HOSTS + candidate="$config_file.provider_dns" + + awk -v policy_file="$policy_file" ' + function emit_policy( line) { + print " # BEGIN ShellCrash provider DNS" + while ((getline line < policy_file) > 0) print line + close(policy_file) + print " # END ShellCrash provider DNS" + policy_emitted = 1 + } + + /^ # BEGIN ShellCrash provider DNS$/ { managed = 1; next } + /^ # END ShellCrash provider DNS$/ { managed = 0; next } + /^ # BEGIN provider DNS sync$/ { managed = 1; next } + /^ # END provider DNS sync$/ { managed = 0; next } + managed { next } + + /^dns:[[:space:]]*$/ { in_dns = 1 } + in_dns && /^ nameserver-policy:[[:space:]]*$/ { + print + emit_policy() + next + } + in_dns && /^ nameserver-policy:[[:space:]]*[^[:space:]]/ { inline_policy = 1 } + in_dns && /^[A-Za-z0-9_-]+:[[:space:]]*/ && $0 !~ /^dns:/ { + if (!policy_emitted && !inline_policy && (getline probe < policy_file) > 0) { + close(policy_file) + print " nameserver-policy:" + emit_policy() + } else { + close(policy_file) + } + in_dns = 0 + } + { print } + + END { + if (in_dns && !policy_emitted && !inline_policy && (getline probe < policy_file) > 0) { + close(policy_file) + print " nameserver-policy:" + emit_policy() + } + } + ' "$config_file" >"$candidate" || return 1 + + awk -v hosts_file="$hosts_file" ' + function emit_hosts( line) { + print " # BEGIN ShellCrash provider DNS hosts" + while ((getline line < hosts_file) > 0) print line + close(hosts_file) + print " # END ShellCrash provider DNS hosts" + hosts_emitted = 1 + } + + /^ # BEGIN ShellCrash provider DNS hosts$/ { managed = 1; next } + /^ # END ShellCrash provider DNS hosts$/ { managed = 0; next } + /^ # BEGIN provider DNS hosts sync$/ { managed = 1; next } + /^ # END provider DNS hosts sync$/ { managed = 0; next } + managed { next } + + /^hosts:[[:space:]]*$/ { + print + emit_hosts() + next + } + /^hosts:[[:space:]]*[^[:space:]]/ { inline_hosts = 1 } + !hosts_emitted && !inline_hosts && /^proxies:[[:space:]]*/ { + if ((getline probe < hosts_file) > 0) { + close(hosts_file) + print "hosts:" + emit_hosts() + } else { + close(hosts_file) + } + } + { print } + + END { + if (!hosts_emitted && !inline_hosts && (getline probe < hosts_file) > 0) { + close(hosts_file) + print "hosts:" + emit_hosts() + } + } + ' "$candidate" >"$candidate.hosts" || return 1 + mv -f "$candidate.hosts" "$config_file" + rm -f "$candidate" +) + +provider_dns_reload() { + provider_dns_init + runtime_config="$TMPDIR/config.yaml" + pid=$(pidof CrashCore 2>/dev/null | awk '{print $1}') + core_bin="$TMPDIR/CrashCore" + [ -x "$core_bin" ] || core_bin="/proc/$pid/exe" + [ -s "$runtime_config" ] && [ -n "$pid" ] && [ -x "$core_bin" ] || return 0 + + candidate="$TMPDIR/config.provider_dns.yaml" + cp -f "$runtime_config" "$candidate" || return 1 + provider_dns_apply_config "$candidate" || return 1 + cmp -s "$runtime_config" "$candidate" && { + rm -f "$candidate" + return 0 + } + "$core_bin" -t -d "$BINDIR" -f "$candidate" >/dev/null 2>&1 || { + rm -f "$candidate" + provider_dns_log 'generated configuration failed validation' + return 1 + } + + backup="$TMPDIR/config.provider_dns.bak" + cp -f "$runtime_config" "$backup" || return 1 + mv -f "$candidate" "$runtime_config" || return 1 + secret=$(sed -n 's/^secret:[[:space:]]*//p' "$runtime_config" | head -n 1 | tr -d "'\"") + [ -z "${db_port:-}" ] && db_port=9999 + http_code=$(curl -fsS --max-time 15 -o /dev/null -w '%{http_code}' \ + -X PUT -H "Authorization: Bearer $secret" -H 'Content-Type: application/json' \ + --data "{\"path\":\"$runtime_config\"}" \ + "http://127.0.0.1:$db_port/configs?force=true" 2>/dev/null || true) + if [ "$http_code" != 204 ]; then + mv -f "$backup" "$runtime_config" + curl -fsS --max-time 15 -o /dev/null -X PUT \ + -H "Authorization: Bearer $secret" -H 'Content-Type: application/json' \ + --data "{\"path\":\"$runtime_config\"}" \ + "http://127.0.0.1:$db_port/configs?force=true" 2>/dev/null || true + provider_dns_log 'hot reload failed; restored the previous configuration' + return 1 + fi + rm -f "$backup" +} diff --git a/scripts/menus/task_cmd.sh b/scripts/menus/task_cmd.sh index 0b1982e20..ed2132d0d 100644 --- a/scripts/menus/task_cmd.sh +++ b/scripts/menus/task_cmd.sh @@ -138,6 +138,9 @@ hotupdate() { #热更新订阅 . "$CRASHDIR"/libs/web_restore.sh && put_save "http://127.0.0.1:$db_port/configs" "{\"path\":\"$CRASHDIR/config.$format\"}" exit $? } +sync_provider_dns() { #同步节点提供者的DNS + . "$CRASHDIR"/libs/provider_dns.sh && provider_dns_sync +} case "$1" in [1-9][0-9][0-9]) diff --git a/scripts/start.sh b/scripts/start.sh index 97e9247fa..ed553ee55 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -68,7 +68,7 @@ stop) logger ShellCrash服务即将关闭...... [ -n "$(pidof CrashCore)" ] && web_save #保存面板配置 #清理定时任务 - cronload | grep -vE '^$|start_legacy_wd.sh|运行时每' > "$TMPDIR"/cron_tmp + cronload | grep -vE '^$|start_legacy_wd.sh|运行时每|ShellCrash-provider-dns' > "$TMPDIR"/cron_tmp cronadd "$TMPDIR"/cron_tmp rm -f "$TMPDIR"/cron_tmp #停止tg_bot diff --git a/scripts/starts/afstart.sh b/scripts/starts/afstart.sh index d798a5179..abbac913b 100644 --- a/scripts/starts/afstart.sh +++ b/scripts/starts/afstart.sh @@ -47,6 +47,9 @@ if [ -n "$test" -o -n "$(pidof CrashCore)" ]; then rm -f "$TMPDIR"/cron_tmp "$TMPDIR"/cron_tmp2 #加载条件任务 [ -s "$TASKCFGDIR"/afstart ] && { . "$TASKCFGDIR"/afstart; } & + [ "$crashcore" = meta ] && [ -s "$CRASHDIR/configs/providers.cfg" ] && [ -s "$CRASHDIR/libs/provider_dns.sh" ] && { + (sleep 10; "$CRASHDIR/task/task.sh" sync_provider_dns) & + } [ -s "$TASKCFGDIR"/affirewall -a -s /etc/init.d/firewall -a ! -f /etc/init.d/firewall.bak ] && { #注入防火墙 line=$(grep -En "fw.* restart" /etc/init.d/firewall | cut -d ":" -f 1) diff --git a/scripts/starts/bfstart.sh b/scripts/starts/bfstart.sh index ce8450f85..e9588112d 100644 --- a/scripts/starts/bfstart.sh +++ b/scripts/starts/bfstart.sh @@ -17,6 +17,10 @@ . "$CRASHDIR"/libs/compare.sh . "$CRASHDIR"/starts/check_geo.sh . "$CRASHDIR"/starts/check_core.sh +[ "$crashcore" = meta ] && [ -s "$CRASHDIR/configs/providers.cfg" ] && [ -s "$CRASHDIR/libs/provider_dns.sh" ] && { + . "$CRASHDIR/libs/provider_dns.sh" + provider_dns_enable +} #缺省值 [ -z "$redir_mod" ] && [ "$USER" = "root" -o "$USER" = "admin" ] && redir_mod='Redir' [ -z "$dns_mod" ] && dns_mod='redir_host' diff --git a/scripts/starts/clash_modify.sh b/scripts/starts/clash_modify.sh index 493c0e200..fe3ccc696 100644 --- a/scripts/starts/clash_modify.sh +++ b/scripts/starts/clash_modify.sh @@ -231,6 +231,10 @@ merger_yaml() { done #合并完整配置文件 cut -c 1- "$TMPDIR"/set.yaml $yaml_dns $yaml_hosts $yaml_user $yaml_others $yaml_add >"$TMPDIR"/config.yaml + [ "$crashcore" = meta ] && [ -s "$CRASHDIR/libs/provider_dns.sh" ] && { + . "$CRASHDIR/libs/provider_dns.sh" + provider_dns_apply_config "$TMPDIR/config.yaml" + } } test_yaml() { diff --git a/tests/fixtures/provider_block.yaml b/tests/fixtures/provider_block.yaml new file mode 100644 index 000000000..eb6954a84 --- /dev/null +++ b/tests/fixtures/provider_block.yaml @@ -0,0 +1,11 @@ +dns: + proxy-server-nameserver: + - https://resolver-a.example/dns-query + - "https://resolver-b.example/dns-query" +proxies: + - name: Node 1 + type: ss + server: edge-a.nodes.example + port: 443 + - {name: Node 2, type: vmess, server: edge-b.nodes.example, port: 443} +proxy-groups: [] diff --git a/tests/fixtures/provider_conflict.yaml b/tests/fixtures/provider_conflict.yaml new file mode 100644 index 000000000..98e6237e3 --- /dev/null +++ b/tests/fixtures/provider_conflict.yaml @@ -0,0 +1,5 @@ +dns: + proxy-server-nameserver: [https://resolver-d.example/dns-query] +proxies: + - {name: Node 4, type: ss, server: edge-a.nodes.example, port: 8443} +proxy-groups: [] diff --git a/tests/fixtures/provider_inline.yaml b/tests/fixtures/provider_inline.yaml new file mode 100644 index 000000000..7da16e2f5 --- /dev/null +++ b/tests/fixtures/provider_inline.yaml @@ -0,0 +1,5 @@ +dns: + nameserver-policy: {unrelated.example: ['https://public-resolver.example/dns-query'], '+.nodes.example': ['https://resolver-c.example/token/dns-query']} +proxies: + - {name: Node 3, type: trojan, server: gateway.nodes.example, port: 443} +proxy-groups: [] diff --git a/tests/provider_dns_test.sh b/tests/provider_dns_test.sh new file mode 100644 index 000000000..2c32a9530 --- /dev/null +++ b/tests/provider_dns_test.sh @@ -0,0 +1,140 @@ +#!/bin/sh + +set -eu + +TEST_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +REPO_DIR=$(CDPATH= cd -- "$TEST_DIR/.." && pwd) +SANDBOX=${TMPDIR:-/tmp}/shellcrash-provider-dns-test.$$ +trap 'rm -rf "$SANDBOX"' EXIT INT TERM + +CRASHDIR=$SANDBOX/crash +TMPDIR=$SANDBOX/tmp +BINDIR=$TMPDIR +TASKCFGDIR=$CRASHDIR/configs/task +PROVIDER_DNS_NO_RELOAD=1 +export CRASHDIR TMPDIR BINDIR TASKCFGDIR PROVIDER_DNS_NO_RELOAD + +mkdir -p "$CRASHDIR/configs" "$TMPDIR/providers" +cp "$TEST_DIR/fixtures/provider_block.yaml" "$TMPDIR/providers/Block.yaml" +cp "$TEST_DIR/fixtures/provider_inline.yaml" "$TMPDIR/providers/Inline.yaml" +cp "$TEST_DIR/fixtures/provider_conflict.yaml" "$TMPDIR/providers/Conflict.yaml" +cat >"$CRASHDIR/configs/providers.cfg" <<'EOF' +Block https://subscription.example/block 3 12 clash.meta +Inline https://subscription.example/inline 3 12 clash.meta +Conflict https://subscription.example/conflict 3 12 clash.meta +EOF + +. "$REPO_DIR/scripts/libs/provider_dns.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + grep -qF "$2" "$1" || fail "$1 does not contain $2" +} + +provider_dns_resolve() { + case "$1:$2" in + edge-a.nodes.example:*resolver-d.example*) echo 203.0.113.40 ;; + edge-a.nodes.example:*) echo 192.0.2.10 ;; + edge-b.nodes.example:*) echo 192.0.2.11 ;; + gateway.nodes.example:*) echo 198.51.100.20 ;; + *) return 1 ;; + esac +} + +block_urls=$(provider_dns_extract_doh "$TMPDIR/providers/Block.yaml") +[ "$(printf '%s\n' "$block_urls" | wc -l | tr -d ' ')" = 2 ] || fail 'block DoH URLs were not parsed' +block_servers=$(provider_dns_extract_servers "$TMPDIR/providers/Block.yaml") +[ "$(printf '%s\n' "$block_servers" | wc -l | tr -d ' ')" = 2 ] || fail 'block proxy servers were not parsed' +inline_urls=$(provider_dns_extract_doh_for_server "$TMPDIR/providers/Inline.yaml" gateway.nodes.example) +[ "$(printf '%s\n' "$inline_urls" | wc -l | tr -d ' ')" = 1 ] || fail 'domain policy DoH was not selected' +printf '%s\n' "$inline_urls" | grep -qF resolver-c.example || fail 'the matching domain policy was not selected' + +provider_dns_sync +assert_contains "$CRASHDIR/configs/provider_dns/hosts.yaml" "'edge-a.nodes.example': 192.0.2.10" +assert_contains "$CRASHDIR/configs/provider_dns/hosts.yaml" "'gateway.nodes.example': 198.51.100.20" +assert_contains "$CRASHDIR/configs/provider_dns/policy.yaml" "https://resolver-c.example/token/dns-query" +[ "$(grep -c edge-a.nodes.example "$CRASHDIR/configs/provider_dns/hosts.yaml")" = 1 ] || fail 'conflicting providers generated duplicate host keys' +assert_contains "$CRASHDIR/configs/provider_dns/hosts.yaml" "'edge-a.nodes.example': 192.0.2.10" + +# The DNS parser skips CNAME records and returns a following A record. +printf '\000\000\201\200\000\001\000\002\000\000\000\000\001a\007example\000\000\001\000\001\300\014\000\005\000\001\000\000\000\000\000\002\300\014\300\014\000\001\000\001\000\000\000\000\000\004\313\000\161\011' >"$SANDBOX/cname-response.bin" +[ "$(provider_dns_parse_a "$SANDBOX/cname-response.bin")" = 203.0.113.9 ] || fail 'CNAME followed by A was not parsed' + +cat >"$SANDBOX/config.yaml" <<'EOF' +dns: + enable: true + # BEGIN provider DNS sync + proxy-server-nameserver: + - 'https://legacy.example/dns-query' + nameserver-policy: + 'legacy.nodes.example': + - 'https://legacy.example/dns-query' + # END provider DNS sync + nameserver-policy: + 'rule-set:cn': [ 223.5.5.5 ] +hosts: + # BEGIN provider DNS hosts sync + 'legacy.nodes.example': 203.0.113.99 + # END provider DNS hosts sync + 'router.lan': 192.168.1.1 +proxies: [] +EOF +provider_dns_apply_config "$SANDBOX/config.yaml" +assert_contains "$SANDBOX/config.yaml" '# BEGIN ShellCrash provider DNS' +assert_contains "$SANDBOX/config.yaml" "'edge-b.nodes.example': 192.0.2.11" +assert_contains "$SANDBOX/config.yaml" "'router.lan': 192.168.1.1" +if grep -q legacy.nodes.example "$SANDBOX/config.yaml"; then + fail 'legacy managed blocks were not removed' +fi + +# Hot reload validates and installs the candidate without losing its path in nested functions. +cat >"$TMPDIR/config.yaml" <<'EOF' +dns: + enable: true +hosts: + 'router.lan': 192.168.1.1 +proxies: [] +EOF +cat >"$TMPDIR/CrashCore" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod +x "$TMPDIR/CrashCore" +pidof() { echo 123; } +curl() { printf 204; } +provider_dns_reload +assert_contains "$TMPDIR/config.yaml" '# BEGIN ShellCrash provider DNS hosts' +[ ! -e "$TMPDIR/config.provider_dns.bak" ] || fail 'hot reload backup was not cleaned up' + +# A changed provider DoH replaces the managed policy on the next sync. +sed 's#resolver-c.example/token#resolver-new.example/token#' "$TMPDIR/providers/Inline.yaml" >"$TMPDIR/providers/Inline.yaml.new" +mv "$TMPDIR/providers/Inline.yaml.new" "$TMPDIR/providers/Inline.yaml" +provider_dns_sync +assert_contains "$CRASHDIR/configs/provider_dns/policy.yaml" 'https://resolver-new.example/token/dns-query' +provider_dns_apply_config "$SANDBOX/config.yaml" +[ "$(grep -c 'BEGIN ShellCrash provider DNS$' "$SANDBOX/config.yaml")" = 1 ] || fail 'managed DNS policy was duplicated' + +# A failed refresh retains the last valid state for that provider. +provider_dns_resolve() { return 1; } +provider_dns_sync +assert_contains "$CRASHDIR/configs/provider_dns/hosts.yaml" "'edge-a.nodes.example': 192.0.2.10" + +# A malformed provider cache also keeps the last valid state. +echo 'proxies: []' >"$TMPDIR/providers/Inline.yaml" +provider_dns_sync +assert_contains "$CRASHDIR/configs/provider_dns/hosts.yaml" "'gateway.nodes.example': 198.51.100.20" + +# Removing a provider removes its records from the aggregate without touching its fallback state. +sed -n '2p' "$CRASHDIR/configs/providers.cfg" >"$CRASHDIR/configs/providers.cfg.new" +mv "$CRASHDIR/configs/providers.cfg.new" "$CRASHDIR/configs/providers.cfg" +provider_dns_sync +if grep -qF edge-a.nodes.example "$CRASHDIR/configs/provider_dns/hosts.yaml"; then + fail 'removed provider remains in the aggregate' +fi +assert_contains "$CRASHDIR/configs/provider_dns/hosts.yaml" gateway.nodes.example + +echo 'provider_dns_test: PASS'