diff --git a/scripts/libs/yaml_dns.sh b/scripts/libs/yaml_dns.sh new file mode 100644 index 000000000..6cb2e1bbd --- /dev/null +++ b/scripts/libs/yaml_dns.sh @@ -0,0 +1,307 @@ +#!/bin/sh +# Copyright (C) Juewuy + +# Extract the top-level dns section without requiring a YAML parser. The +# merger below normalizes block and one-line flow mappings before overlaying +# fields, so the final configuration contains only one dns key. + +yaml_dns_extract() { + awk ' + function top_level(line) { + return line !~ /^[[:space:]#]/ && line ~ /^[^:]+:/ + } + function brace_delta(line, i, c, quote, escaped, delta) { + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (quote) { + if (quote == "\042" && escaped) escaped = 0 + else if (quote == "\042" && c == "\\") escaped = 1 + else if (c == quote) quote = 0 + } else if (c == "\047" || c == "\042") quote = c + else if (c == "#" && (i == 1 || substr(line, i - 1, 1) ~ /[[:space:]]/)) break + else if (c == "{") delta++ + else if (c == "}") delta-- + } + return delta + } + { + if (!found) { + if ($0 ~ /^dns:[[:space:]]*\{/) { + print + flow = 1 + balance = brace_delta($0) + if (balance <= 0) exit + found = 1 + next + } + if ($0 ~ /^dns:[[:space:]]*($|#)/) { + print + found = 1 + } + next + } + if (flow) { + print + balance += brace_delta($0) + if (balance <= 0) exit + next + } + if (top_level($0)) exit + print + }' "$1" +} + +yaml_dns_without() { + awk ' + function top_level(line) { + return line !~ /^[[:space:]#]/ && line ~ /^[^:]+:/ + } + function brace_delta(line, i, c, quote, escaped, delta) { + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (quote) { + if (quote == "\042" && escaped) escaped = 0 + else if (quote == "\042" && c == "\\") escaped = 1 + else if (c == quote) quote = 0 + } else if (c == "\047" || c == "\042") quote = c + else if (c == "#" && (i == 1 || substr(line, i - 1, 1) ~ /[[:space:]]/)) break + else if (c == "{") delta++ + else if (c == "}") delta-- + } + return delta + } + { + if (!in_dns) { + if ($0 ~ /^dns:[[:space:]]*\{/) { + in_dns = 1 + flow = 1 + balance = brace_delta($0) + if (balance <= 0) in_dns = 0 + next + } + if ($0 ~ /^dns:[[:space:]]*($|#)/) { + in_dns = 1 + next + } + print + next + } + if (flow) { + balance += brace_delta($0) + if (balance <= 0) in_dns = 0 + next + } + if (top_level($0)) { + in_dns = 0 + print + next + } + }' "$1" +} + +yaml_top_level_section() { + awk -v section="$2" ' + function top_level(line) { + return line !~ /^[[:space:]#]/ && line ~ /^[^:]+:/ + } + { + if (!started && $0 ~ section) started = 1 + if (started && top_level($0) && $0 !~ section) exit + if (started) print + }' "$1" +} + +yaml_dns_merge() { + awk -v original="${1:-/dev/null}" \ + -v managed="${2:-/dev/null}" \ + -v user="${3:-/dev/null}" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + function strip_comment(line, i, c, quote, escaped) { + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (quote) { + if (quote == "\042" && escaped) escaped = 0 + else if (quote == "\042" && c == "\\") escaped = 1 + else if (c == quote) quote = 0 + } else if (c == "\047" || c == "\042") quote = c + else if (c == "#" && (i == 1 || substr(line, i - 1, 1) ~ /[[:space:]]/)) + return substr(line, 1, i - 1) + } + return line + } + function brace_delta(line, i, c, quote, escaped, delta) { + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (quote) { + if (quote == "\042" && escaped) escaped = 0 + else if (quote == "\042" && c == "\\") escaped = 1 + else if (c == quote) quote = 0 + } else if (c == "\047" || c == "\042") quote = c + else if (c == "#" && (i == 1 || substr(line, i - 1, 1) ~ /[[:space:]]/)) break + else if (c == "{") delta++ + else if (c == "}") delta-- + } + return delta + } + function add_entry(source, key, value) { + if (!(source SUBSEP key in entry)) { + order[source, ++count[source]] = key + } + entry[source, key] = value + } + function append_entry(source, key, line) { + if (entry[source, key] == "") entry[source, key] = line + else entry[source, key] = entry[source, key] "\n" line + } + function flow_pair(source, pair, i, c, depth_brace, depth_square, quote, escaped, colon, key, value) { + pair = trim(pair) + colon = 0 + depth_brace = depth_square = 0 + quote = escaped = 0 + for (i = 1; i <= length(pair); i++) { + c = substr(pair, i, 1) + if (quote) { + if (quote == "\042" && escaped) escaped = 0 + else if (quote == "\042" && c == "\\") escaped = 1 + else if (c == quote) quote = 0 + continue + } + if (c == "\047") quote = "\047" + else if (c == "\042") quote = "\042" + else if (c == "{") depth_brace++ + else if (c == "}") depth_brace-- + else if (c == "[") depth_square++ + else if (c == "]") depth_square-- + else if (c == ":" && depth_brace == 0 && depth_square == 0) { + colon = i + break + } + } + if (!colon) return + key = trim(substr(pair, 1, colon - 1)) + value = trim(substr(pair, colon + 1)) + if ((substr(key, 1, 1) == "\047" && substr(key, length(key), 1) == "\047") || + (substr(key, 1, 1) == "\042" && substr(key, length(key), 1) == "\042")) + key = substr(key, 2, length(key) - 2) + add_entry(source, key, " " key ": " value) + } + function parse_flow(source, text, i, c, depth_brace, depth_square, quote, escaped, start) { + text = trim(strip_comment(text)) + if (substr(text, 1, 1) == "{") text = substr(text, 2) + if (substr(text, length(text), 1) == "}") text = substr(text, 1, length(text) - 1) + start = 1 + depth_brace = depth_square = 0 + quote = escaped = 0 + for (i = 1; i <= length(text); i++) { + c = substr(text, i, 1) + if (quote) { + if (quote == "\042" && escaped) escaped = 0 + else if (quote == "\042" && c == "\\") escaped = 1 + else if (c == quote) quote = 0 + continue + } + if (c == "\047") quote = "\047" + else if (c == "\042") quote = "\042" + else if (c == "{") depth_brace++ + else if (c == "}") depth_brace-- + else if (c == "[") depth_square++ + else if (c == "]") depth_square-- + else if (c == "," && depth_brace == 0 && depth_square == 0) { + flow_pair(source, substr(text, start, i - start)) + start = i + 1 + } + } + flow_pair(source, substr(text, start)) + } + function parse_file(file, source, line, rest, key, value) { + started = flow = balance = 0 + current = "" + flow_text = "" + while ((getline line < file) > 0) { + if (!started) { + if (line ~ /^dns:[[:space:]]*\{/) { + rest = line + sub(/^dns:[[:space:]]*/, "", rest) + flow_text = strip_comment(rest) + flow = 1 + balance = brace_delta(line) + started = 1 + if (balance <= 0) { + parse_flow(source, flow_text) + started = flow = 0 + } + } else if (line ~ /^dns:[[:space:]]*($|#)/) { + started = 1 + } + continue + } + if (flow) { + flow_text = flow_text " " trim(strip_comment(line)) + balance += brace_delta(line) + if (balance <= 0) { + parse_flow(source, flow_text) + started = flow = 0 + } + continue + } + if (line ~ /^[[:space:]][[:space:]][A-Za-z0-9_-]+[[:space:]]*:/) { + if (current != "") add_entry(source, key, value) + key = line + sub(/^[[:space:]]+/, "", key) + sub(/[[:space:]]*:.*/, "", key) + value = line + current = key + } else if (current != "") { + value = value "\n" line + } + } + if (current != "") add_entry(source, key, value) + close(file) + } + function managed_key(key) { + return key == "enable" || key == "listen" || key == "use-hosts" || + key == "ipv6" || key == "default-nameserver" || key == "direct-nameserver" || + key == "enhanced-mode" || key == "fake-ip-range" || key == "fake-ip-range6" || + key == "fake-ip-filter" || key == "respect-rules" || + key == "proxy-server-nameserver" || key == "nameserver" + } + BEGIN { + parse_file(original, "original") + parse_file(managed, "managed") + parse_file(user, "user") + print "dns:" + for (i = 1; i <= count["original"]; i++) { + key = order["original", i] + # nameserver-policy is intentionally passthrough: ShellCrash + # generated policy must not replace subscription-specific routes. + if (!managed_key(key) && !("user" SUBSEP key in entry)) + print entry["original", key] + } + for (i = 1; i <= count["managed"]; i++) { + key = order["managed", i] + if (!("user" SUBSEP key in entry) && + !(key == "nameserver-policy" && ("original" SUBSEP key in entry))) + print entry["managed", key] + } + for (i = 1; i <= count["user"]; i++) { + key = order["user", i] + print entry["user", key] + } + }' +} + +case "${1:-}" in +extract) + yaml_dns_extract "$2" + ;; +without) + yaml_dns_without "$2" + ;; +merge) + yaml_dns_merge "$2" "$3" "$4" + ;; +esac diff --git a/scripts/menus/dns.sh b/scripts/menus/dns.sh index 72b0491ae..9cb031c0b 100644 --- a/scripts/menus/dns.sh +++ b/scripts/menus/dns.sh @@ -316,7 +316,9 @@ set_dns_adv() { if echo "$crashcore" | grep -qE 'meta|singbox'; then dns_nameserver='https://dns.alidns.com/dns-query, https://doh.pub/dns-query' dns_fallback='https://cloudflare-dns.com/dns-query, https://dns.google/dns-query, https://doh.opendns.com/dns-query' - dns_resolver='https://223.5.5.5/dns-query, 2400:3200::1' + # Mihomo's default-nameserver resolver must be plain IPs; + # encrypted resolvers belong in dns_nameserver/dns_fallback. + dns_resolver='223.5.5.5, 2400:3200::1' setconfig dns_nameserver "'$dns_nameserver'" setconfig dns_fallback "'$dns_fallback'" setconfig dns_resolver "'$dns_resolver'" diff --git a/scripts/menus/providers_clash.sh b/scripts/menus/providers_clash.sh index f8bc313a2..a9054c23c 100644 --- a/scripts/menus/providers_clash.sh +++ b/scripts/menus/providers_clash.sh @@ -7,6 +7,7 @@ __IS_PROVIDERS_CLASH=1 load_lang providers . "$CRASHDIR"/libs/web_get_bin.sh +. "$CRASHDIR"/libs/yaml_dns.sh # 生成clash的providers配置文件 gen_providers() { @@ -29,11 +30,14 @@ gen_providers() { fi # 生成proxy_providers模块 mkdir -p "$TMPDIR"/providers + # Keep an optional DNS section from the provider template. It is otherwise + # lost when the template is reduced to proxy providers, groups and rules. + yaml_dns_extract "$TMPDIR"/provider_temp_file >"$TMPDIR"/providers/dns.yaml # 预创建文件并写入对应文件头 echo 'proxy-providers:' >"$TMPDIR"/providers/providers.yaml # 切割模版文件 sed -n '/^proxy-groups:/,/^[a-z]/ { /^rule/d; p; }' "$TMPDIR"/provider_temp_file >"$TMPDIR"/providers/proxy-groups.yaml - sed -n '/^rule/,$p' "$TMPDIR"/provider_temp_file >"$TMPDIR"/providers/rules.yaml + yaml_top_level_section "$TMPDIR"/provider_temp_file '^rule' >"$TMPDIR"/providers/rules.yaml rm -rf "$TMPDIR"/provider_temp_file # 基于单订阅生成providers模块 if [ -n "$1" ]; then @@ -58,7 +62,7 @@ gen_providers() { fi # 修饰模版文件并合并 sed -i "s/{providers_tags}/$providers_tags/g" "$TMPDIR"/providers/proxy-groups.yaml - cut -c 1- "$TMPDIR"/providers/providers.yaml "$TMPDIR"/providers/proxy-groups.yaml "$TMPDIR"/providers/rules.yaml >"$TMPDIR"/config.yaml + cut -c 1- "$TMPDIR"/providers/providers.yaml "$TMPDIR"/providers/proxy-groups.yaml "$TMPDIR"/providers/rules.yaml "$TMPDIR"/providers/dns.yaml >"$TMPDIR"/config.yaml rm -rf "$TMPDIR"/providers # 调用内核测试 . "$CRASHDIR"/starts/check_core.sh && check_core && "$TMPDIR"/CrashCore -t -d "$BINDIR" -f "$TMPDIR"/config.yaml diff --git a/scripts/starts/clash_modify.sh b/scripts/starts/clash_modify.sh index 493c0e200..8706bb6d9 100644 --- a/scripts/starts/clash_modify.sh +++ b/scripts/starts/clash_modify.sh @@ -25,10 +25,16 @@ prepare_clash_base_config() { fi } } - #dns配置 - [ -z "$(cat "$CRASHDIR"/yamls/user.yaml 2>/dev/null | grep '^dns:')" ] && { - [ "$crashcore" != meta ] && dns_resolver='223.5.5.5' - cat >"$TMPDIR"/dns.yaml <"$TMPDIR"/original-dns.yaml + if [ -s "$CRASHDIR"/yamls/user.yaml ]; then + yaml_dns_extract "$CRASHDIR"/yamls/user.yaml >"$TMPDIR"/user-dns.yaml + yaml_dns_without "$CRASHDIR"/yamls/user.yaml >"$TMPDIR"/user.yaml + fi + [ "$crashcore" != meta ] && dns_resolver='223.5.5.5' + cat >"$TMPDIR"/managed-dns.yaml </dev/null | grep -v '#' | sed "s/^/ - '/" | sed "s/$/'/" >>"$TMPDIR"/dns.yaml + cat "$CRASHDIR"/configs/fake_ip_filter "$CRASHDIR"/configs/fake_ip_filter.list 2>/dev/null | grep -v '#' | sed "s/^/ - '/" | sed "s/$/'/" >>"$TMPDIR"/managed-dns.yaml else - echo " - '+.*'" >>"$TMPDIR"/dns.yaml #使用fake-ip模拟redir_host + echo " - '+.*'" >>"$TMPDIR"/managed-dns.yaml #使用fake-ip模拟redir_host fi #mix模式fakeip绕过cn - [ "$dns_mod" = "mix" ] && echo ' - "rule-set:cn"' >>"$TMPDIR"/dns.yaml + [ "$dns_mod" = "mix" ] && echo ' - "rule-set:cn"' >>"$TMPDIR"/managed-dns.yaml #mix模式和route模式插入分流设置 if [ "$dns_mod" = "mix" ] || [ "$dns_mod" = "route" ]; then [ "$dns_protect" != "OFF" ] && dns_final="$dns_fallback" || dns_final="$dns_nameserver" - cat >>"$TMPDIR"/dns.yaml <>"$TMPDIR"/managed-dns.yaml <>"$TMPDIR"/dns.yaml <>"$TMPDIR"/managed-dns.yaml <"$TMPDIR"/dns.yaml #域名嗅探配置 [ "$sniffer" = "ON" ] && [ "$crashcore" = "meta" ] && sniffer_set="sniffer: {enable: true, parse-pure-ip: true, skip-domain: ['+.push.apple.com', 'Mijia Cloud'], sniff: {http: {ports: [80, 8080-8880], override-destination: true}, tls: {ports: [443, 8443]}, quic: {ports: [443, 8443]}}}" [ "$crashcore" = "clashpre" ] && [ "$dns_mod" = "redir_host" -o "$sniffer" = "ON" ] && exper="experimental: {ignore-resolve-fail: true, interface-name: en0,sniff-tls-sni: true}" @@ -212,7 +220,7 @@ merger_yaml() { sed -i 's/^ *-/ -/g' "$TMPDIR"/rules.yaml #合并文件 [ -s "$CRASHDIR"/yamls/user.yaml ] && { - yaml_user="$CRASHDIR"/yamls/user.yaml + yaml_user="$TMPDIR"/user.yaml #set和user去重,且优先使用user.yaml cp -f "$TMPDIR"/set.yaml "$TMPDIR"/set_bak.yaml for char in mode allow-lan log-level tun experimental external-ui-url interface-name dns store-selected unified-delay; do @@ -253,7 +261,7 @@ finalize_clash_yaml() { #建立软连接 [ ""$TMPDIR"" = ""$BINDIR"" ] || ln -sf "$TMPDIR"/config.yaml "$BINDIR"/config.yaml 2>/dev/null || cp -f "$TMPDIR"/config.yaml "$BINDIR"/config.yaml #清理缓存 - for char in $yaml_char set set_bak dns hosts; do + for char in $yaml_char set set_bak dns managed-dns original-dns user-dns user hosts; do rm -f "$TMPDIR"/${char}.yaml done } diff --git a/tests/test_yaml_dns.sh b/tests/test_yaml_dns.sh new file mode 100644 index 000000000..d01464424 --- /dev/null +++ b/tests/test_yaml_dns.sh @@ -0,0 +1,106 @@ +#!/bin/sh + +set -eu + +test_tmp=$(mktemp -d "${TMPDIR:-/tmp}/shellcrash-dns-test.XXXXXX") +trap 'rm -rf "$test_tmp"' EXIT + +. "$(dirname "$0")/../scripts/libs/yaml_dns.sh" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + grep -Fq -- "$1" "$2" || fail "$1 not found in $2" +} + +assert_not_contains() { + grep -Fq -- "$1" "$2" && fail "$1 unexpectedly found in $2" || : +} + +# A subscription block keeps provider-specific DNS policy and unknown fields, +# while ShellCrash-managed values still replace the subscription nameserver. +printf '%s\n' \ + 'dns:' \ + ' nameserver-policy:' \ + ' "+.v51124-4.qpon": [1.1.1.1]' \ + ' fallback-filter: {geoip: true}' \ + ' fallback-filter: {geosite: true}' \ + ' nameserver: [9.9.9.9]' \ + 'proxies: []' >"$test_tmp/original.yaml" +printf '%s\n' \ + 'dns:' \ + ' nameserver: [223.5.5.5]' \ + ' enable: true' >"$test_tmp/managed.yaml" +yaml_dns_merge "$test_tmp/original.yaml" "$test_tmp/managed.yaml" /dev/null >"$test_tmp/block.out" +assert_contains '"+.v51124-4.qpon": [1.1.1.1]' "$test_tmp/block.out" +assert_contains 'fallback-filter: {geosite: true}' "$test_tmp/block.out" +assert_not_contains 'fallback-filter: {geoip: true}' "$test_tmp/block.out" +[ "$(grep -c '^ fallback-filter:' "$test_tmp/block.out")" = 1 ] || fail 'repeated DNS key was not reduced' +assert_contains 'nameserver: [223.5.5.5]' "$test_tmp/block.out" +assert_not_contains 'nameserver: [9.9.9.9]' "$test_tmp/block.out" +[ "$(grep -c '^dns:' "$test_tmp/block.out")" = 1 ] || fail 'block output has duplicate dns keys' + +# Common one-line flow mappings are normalized before merging. +printf '%s\n' \ + 'dns: {nameserver-policy: {"+.flow": [1.1.1.1]}, fallback-filter: {geoip: true}, nameserver: [9.9.9.9]}' \ + 'proxies: []' >"$test_tmp/flow.yaml" +yaml_dns_merge "$test_tmp/flow.yaml" "$test_tmp/managed.yaml" /dev/null >"$test_tmp/flow.out" +assert_contains '"+.flow": [1.1.1.1]' "$test_tmp/flow.out" +assert_contains 'fallback-filter: {geoip: true}' "$test_tmp/flow.out" +[ "$(grep -c '^dns:' "$test_tmp/flow.out")" = 1 ] || fail 'flow output has duplicate dns keys' + +printf '%s\n' \ + 'dns: {nameserver-policy: {"+.comment": [1.1.1.1]}, fallback-filter: {geoip: true}} # trailing comment' \ + 'proxies: []' >"$test_tmp/flow-comment.yaml" +yaml_dns_merge "$test_tmp/flow-comment.yaml" "$test_tmp/managed.yaml" /dev/null >"$test_tmp/flow-comment.out" +assert_contains '"+.comment": [1.1.1.1]' "$test_tmp/flow-comment.out" +assert_contains 'fallback-filter: {geoip: true}' "$test_tmp/flow-comment.out" +assert_not_contains 'fallback-filter: {geoip: true}}' "$test_tmp/flow-comment.out" + +# No subscription DNS still produces the managed DNS section. +: >"$test_tmp/empty.yaml" +yaml_dns_merge "$test_tmp/empty.yaml" "$test_tmp/managed.yaml" /dev/null >"$test_tmp/empty.out" +assert_contains 'enable: true' "$test_tmp/empty.out" + +# User DNS wins field-by-field and is removed from the ordinary user overlay. +printf '%s\n' \ + 'dns:' \ + ' nameserver: [8.8.8.8]' \ + ' custom-user: yes' \ + 'mode: Rule' >"$test_tmp/user.yaml" +yaml_dns_merge "$test_tmp/original.yaml" "$test_tmp/managed.yaml" "$test_tmp/user.yaml" >"$test_tmp/user.out" +assert_contains 'nameserver: [8.8.8.8]' "$test_tmp/user.out" +assert_contains 'custom-user: yes' "$test_tmp/user.out" +assert_not_contains 'nameserver: [223.5.5.5]' "$test_tmp/user.out" +yaml_dns_without "$test_tmp/user.yaml" >"$test_tmp/user-without.out" +assert_contains 'mode: Rule' "$test_tmp/user-without.out" +if grep -q '^dns:' "$test_tmp/user-without.out"; then + fail 'user overlay still contains dns' +fi + +# A provider template may place dns after rules. Section extraction must stop +# at that next top-level key before dns.yaml is appended. +printf '%s\n' \ + 'proxy-groups: []' \ + 'rule-providers:' \ + ' sample: {type: http, behavior: classical}' \ + 'rules:' \ + ' - MATCH,DIRECT' \ + 'dns:' \ + ' nameserver-policy: {"+.provider": [1.1.1.1]}' >"$test_tmp/provider.yaml" +yaml_top_level_section "$test_tmp/provider.yaml" '^rule' >"$test_tmp/provider-rules.out" +assert_contains 'rules:' "$test_tmp/provider-rules.out" +assert_not_contains 'dns:' "$test_tmp/provider-rules.out" +yaml_dns_extract "$test_tmp/provider.yaml" >"$test_tmp/provider-dns.out" +cat "$test_tmp/provider-rules.out" "$test_tmp/provider-dns.out" >"$test_tmp/provider-config.out" +[ "$(grep -c '^dns:' "$test_tmp/provider-config.out")" = 1 ] || + fail 'provider output has duplicate dns keys' + +# The one-click encrypted DNS preset must keep the resolver as IP literals. +assert_not_contains "dns_resolver='https://" "$(dirname "$0")/../scripts/menus/dns.sh" +assert_contains "dns_resolver='223.5.5.5, 2400:3200::1'" "$(dirname "$0")/../scripts/menus/dns.sh" + +printf '%s\n' 'yaml dns tests passed'