Add prioritized DNS failover
This commit is contained in:
@@ -33,6 +33,8 @@ const fixture = {
|
||||
download_lists_via_proxy: '1',
|
||||
download_subscriptions_via_proxy: '1',
|
||||
download_lists_via_proxy_section: 'legacy-urltest',
|
||||
dns_server: '9.9.9.9',
|
||||
bootstrap_dns_server: '1.1.1.1',
|
||||
routing_excluded_ips: [ '192.0.2.0/24' ]
|
||||
},
|
||||
rule: [
|
||||
@@ -142,6 +144,9 @@ const childByType = type => config[type] || [];
|
||||
const subscriptionUrls = childByType('subscription_url');
|
||||
const urltests = childByType('urltest');
|
||||
|
||||
assert(JSON.stringify(config.settings.dns_server) === JSON.stringify(['9.9.9.9']), 'legacy main DNS scalar migrated to ordered list');
|
||||
assert(JSON.stringify(config.settings.bootstrap_dns_server) === JSON.stringify(['1.1.1.1']), 'legacy Bootstrap DNS scalar migrated to ordered list');
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
console.error(message);
|
||||
|
||||
@@ -23,7 +23,17 @@ JSON
|
||||
context="$(cat "$WORK_DIR/context.json")"
|
||||
|
||||
validate_fixture() {
|
||||
PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$1" "$context"
|
||||
local source="$1"
|
||||
local normalized="$WORK_DIR/normalized-$(basename "$source")"
|
||||
node - "$source" "$normalized" <<'JS'
|
||||
const fs = require('fs');
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
input.settings ??= { '.name': 'settings', '.type': 'settings' };
|
||||
if (input.settings.dns_server === undefined) input.settings.dns_server = ['77.88.8.8'];
|
||||
if (input.settings.bootstrap_dns_server === undefined) input.settings.bootstrap_dns_server = ['77.88.8.8'];
|
||||
fs.writeFileSync(process.argv[3], JSON.stringify(input));
|
||||
JS
|
||||
PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$normalized" "$context"
|
||||
}
|
||||
|
||||
assert_rejects() {
|
||||
@@ -48,6 +58,14 @@ cat >"$WORK_DIR/valid.json" <<'JSON'
|
||||
"list_update_enabled": "1",
|
||||
"update_interval": "1d",
|
||||
"latency_test_url": "https://latency.example/generate_204",
|
||||
"dns_type": "doh",
|
||||
"dns_server": [ "dns.google/dns-query", "cloudflare-dns.com/dns-query" ],
|
||||
"bootstrap_dns_server": [ "1.1.1.1", "8.8.8.8" ],
|
||||
"dns_check_interval": "10s",
|
||||
"dns_recovery_check_interval": "60s",
|
||||
"dns_check_timeout": "2s",
|
||||
"dns_detour_enabled": "1",
|
||||
"dns_detour_section": "proxy",
|
||||
"download_lists_via_proxy": "1",
|
||||
"download_lists_via_proxy_section": "proxy"
|
||||
},
|
||||
@@ -116,6 +134,74 @@ JSON
|
||||
|
||||
validate_fixture "$WORK_DIR/valid.json"
|
||||
|
||||
cat >"$WORK_DIR/bad-dns-duration.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_server": [ "1.1.1.1", "8.8.8.8" ],
|
||||
"bootstrap_dns_server": [ "77.88.8.8" ],
|
||||
"dns_check_interval": "ten seconds"
|
||||
},
|
||||
"section": []
|
||||
}
|
||||
JSON
|
||||
assert_rejects "bad DNS interval" "$WORK_DIR/bad-dns-duration.json" "settings.dns_check_interval"
|
||||
|
||||
cat >"$WORK_DIR/bad-dns-server.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_server": [ "1.1.1.1", "bad value" ],
|
||||
"bootstrap_dns_server": [ "77.88.8.8" ]
|
||||
},
|
||||
"section": []
|
||||
}
|
||||
JSON
|
||||
assert_rejects "bad DNS server" "$WORK_DIR/bad-dns-server.json" "Invalid main DNS server"
|
||||
|
||||
cat >"$WORK_DIR/empty-main-dns.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_server": [],
|
||||
"bootstrap_dns_server": [ "77.88.8.8" ]
|
||||
},
|
||||
"section": []
|
||||
}
|
||||
JSON
|
||||
assert_rejects "empty main DNS list" "$WORK_DIR/empty-main-dns.json" "At least one main DNS server is required"
|
||||
|
||||
cat >"$WORK_DIR/empty-bootstrap-dns.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_server": [ "77.88.8.8" ],
|
||||
"bootstrap_dns_server": []
|
||||
},
|
||||
"section": []
|
||||
}
|
||||
JSON
|
||||
assert_rejects "empty Bootstrap DNS list" "$WORK_DIR/empty-bootstrap-dns.json" "At least one Bootstrap DNS server is required"
|
||||
|
||||
cat >"$WORK_DIR/bad-dns-detour-bypass.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_detour_enabled": "1",
|
||||
"dns_detour_section": "bypass"
|
||||
},
|
||||
"section": [
|
||||
{ ".name": "bypass", ".type": "section", "enabled": "1", "action": "bypass" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
assert_rejects "DNS detour bypass" "$WORK_DIR/bad-dns-detour-bypass.json" "unsupported action 'bypass'"
|
||||
|
||||
cat >"$WORK_DIR/bad-direct-action.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings" },
|
||||
@@ -262,7 +348,7 @@ assert_rejects "bad country" "$WORK_DIR/bad-country.json" "Invalid country code
|
||||
provider_context="$(node -e 'const fs=require("fs"); const c=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); c.byedpi_installed=true; process.stdout.write(JSON.stringify(c));' "$WORK_DIR/context.json")"
|
||||
cat >"$WORK_DIR/bad-byedpi.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings" },
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": [ "77.88.8.8" ], "bootstrap_dns_server": [ "77.88.8.8" ] },
|
||||
"section": [
|
||||
{ ".name": "bye", ".type": "section", "enabled": "1", "action": "byedpi", "byedpi_cmd_opts": "--port 1080 --disorder 3" }
|
||||
]
|
||||
@@ -283,7 +369,7 @@ ln -s "$PODKOP_LIB/providers" "$runtime_lib/providers"
|
||||
touch "$WORK_DIR/ciadpi-provider"
|
||||
cat >"$WORK_DIR/bad-byedpi-runtime-state.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings" },
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": [ "77.88.8.8" ], "bootstrap_dns_server": [ "77.88.8.8" ] },
|
||||
"section": [
|
||||
{ ".name": "bye", ".type": "section", "enabled": "1", "action": "byedpi", "byedpi_cmd_opts": "--port 1080 --disorder 3" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib"
|
||||
GENERATOR="$PODKOP_LIB/singbox/generator.uc"
|
||||
FAILOVER="$PODKOP_LIB/singbox/dns_failover.uc"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
generate() {
|
||||
local fixture="$1"
|
||||
local output="$2"
|
||||
local state="${3:-$WORK_DIR/missing-state.json}"
|
||||
PODKOP_LIB="$PODKOP_LIB" \
|
||||
PODKOP_DNS_FAILOVER_STATE_FILE="$state" \
|
||||
ucode -L "$PODKOP_LIB" "$GENERATOR" generate-config-fixture "$fixture" "$output" 192.168.1.1 0
|
||||
}
|
||||
|
||||
cat >"$WORK_DIR/single.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_type": "udp",
|
||||
"dns_server": "77.88.8.8",
|
||||
"bootstrap_dns_server": "77.88.8.8"
|
||||
},
|
||||
"section": [
|
||||
{
|
||||
".name": "direct",
|
||||
".type": "section",
|
||||
"enabled": "1",
|
||||
"action": "bypass",
|
||||
"domain_suffix": [ "example.org" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$WORK_DIR/multi.json" <<'JSON'
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_type": "doh",
|
||||
"dns_server": [ "dns.google/dns-query", "cloudflare-dns.com/dns-query" ],
|
||||
"bootstrap_dns_server": [ "1.1.1.1", "8.8.8.8" ],
|
||||
"dns_check_interval": "10s",
|
||||
"dns_recovery_check_interval": "60s",
|
||||
"dns_check_timeout": "2s",
|
||||
"dns_detour_enabled": "1",
|
||||
"dns_detour_section": "proxy"
|
||||
},
|
||||
"section": [
|
||||
{
|
||||
".name": "proxy",
|
||||
".type": "section",
|
||||
"enabled": "1",
|
||||
"action": "outbound",
|
||||
"outbound_json": "{\"type\":\"direct\",\"tag\":\"fixture\"}",
|
||||
"domain_suffix": [ "example.org" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$WORK_DIR/state.json" <<'JSON'
|
||||
{
|
||||
"version": 1,
|
||||
"dns_type": "doh",
|
||||
"dns_detour": "proxy-out",
|
||||
"main_servers": [ "dns.google/dns-query", "cloudflare-dns.com/dns-query" ],
|
||||
"bootstrap_servers": [ "1.1.1.1", "8.8.8.8" ],
|
||||
"main_index": 1,
|
||||
"bootstrap_index": 1
|
||||
}
|
||||
JSON
|
||||
|
||||
generate "$WORK_DIR/single.json" "$WORK_DIR/single-config.json"
|
||||
generate "$WORK_DIR/multi.json" "$WORK_DIR/multi-config.json" "$WORK_DIR/state.json"
|
||||
|
||||
ucode -e '
|
||||
let fs = require("fs");
|
||||
|
||||
function cfg(path) { return json(fs.readfile(path)); }
|
||||
function assert(value, message) { if (!value) { warn("FAIL: ", message, "\n"); exit(1); } }
|
||||
function find_tag(values, tag) { for (let value in values || []) if (value.tag == tag) return value; return null; }
|
||||
function count_prefix(values, prefix) { let count = 0; for (let value in values || []) if (index(value.tag || "", prefix) == 0) count++; return count; }
|
||||
|
||||
let single = cfg(ARGV[0]);
|
||||
assert(length(single.dns.servers) == 3, "singleton keeps the legacy three-server shape");
|
||||
assert(length(single.inbounds) == 3, "singleton adds no health inbounds");
|
||||
assert(count_prefix(single.dns.servers, "dns-health-") == 0, "singleton adds no health servers");
|
||||
assert(find_tag(single.dns.servers, "dns-server").server == "77.88.8.8", "singleton main DNS preserved");
|
||||
assert(single.route.default_domain_resolver == "dns-server", "singleton keeps main default resolver");
|
||||
|
||||
let multi = cfg(ARGV[1]);
|
||||
assert(length(multi.dns.servers) == 7, "two main and two bootstrap candidates are generated");
|
||||
assert(length(multi.inbounds) == 8, "candidate and active health inbounds are generated");
|
||||
assert(count_prefix(multi.dns.servers, "dns-health-main-") == 2, "main health servers generated");
|
||||
assert(count_prefix(multi.dns.servers, "dns-health-bootstrap-") == 2, "bootstrap health servers generated");
|
||||
let main = find_tag(multi.dns.servers, "dns-server");
|
||||
let bootstrap = find_tag(multi.dns.servers, "bootstrap-dns-server");
|
||||
assert(main.server == "cloudflare-dns.com", "runtime main index selects the second server");
|
||||
assert(main.detour == "proxy-out", "main DNS uses selected section detour");
|
||||
assert(main.domain_resolver == "bootstrap-dns-server", "main hostname uses direct bootstrap resolver");
|
||||
assert(bootstrap.server == "8.8.8.8" && bootstrap.detour == null, "bootstrap index selects direct second server");
|
||||
assert(multi.route.default_domain_resolver == "bootstrap-dns-server", "detour mode breaks endpoint DNS cycles with bootstrap");
|
||||
let health_rules = 0;
|
||||
for (let rule in multi.dns.rules || []) {
|
||||
if (index(rule.inbound || "", "dns-health-") == 0) {
|
||||
health_rules++;
|
||||
assert(rule.disable_cache === true, "health checks bypass DNS cache");
|
||||
}
|
||||
}
|
||||
assert(health_rules == 5, "each candidate and the canonical active DNS have an inbound-specific rule");
|
||||
' "$WORK_DIR/single-config.json" "$WORK_DIR/multi-config.json"
|
||||
|
||||
cat >"$WORK_DIR/select-state.json" <<'JSON'
|
||||
{
|
||||
"main_servers": [ "first", "second", "third" ],
|
||||
"bootstrap_servers": [ "a", "b" ],
|
||||
"main_index": 0,
|
||||
"bootstrap_index": 1
|
||||
}
|
||||
JSON
|
||||
cat >"$WORK_DIR/alive-failover.json" <<'JSON'
|
||||
{ "0": false, "1": true, "2": true }
|
||||
JSON
|
||||
cat >"$WORK_DIR/alive-recovery.json" <<'JSON'
|
||||
{ "0": true, "1": true }
|
||||
JSON
|
||||
|
||||
selected="$(ucode -L "$PODKOP_LIB" "$FAILOVER" select-fixture "$WORK_DIR/select-state.json" "$WORK_DIR/alive-failover.json" main 0)"
|
||||
printf '%s' "$selected" | grep -Eq '"index"[[:space:]]*:[[:space:]]*1' || fail "dead active DNS must select the first live server below"
|
||||
printf '%s' "$selected" | grep -Eq '"reason"[[:space:]]*:[[:space:]]*"active_dead"' || fail "failover reason"
|
||||
|
||||
selected="$(ucode -L "$PODKOP_LIB" "$FAILOVER" select-fixture "$WORK_DIR/select-state.json" "$WORK_DIR/alive-recovery.json" bootstrap 1)"
|
||||
printf '%s' "$selected" | grep -Eq '"index"[[:space:]]*:[[:space:]]*0' || fail "recovery must return to the highest live priority"
|
||||
printf '%s' "$selected" | grep -Eq '"reason"[[:space:]]*:[[:space:]]*"recovery"' || fail "recovery reason"
|
||||
|
||||
cat >"$WORK_DIR/verify-previous.json" <<'JSON'
|
||||
{ "main_index": 0, "bootstrap_index": 0, "bootstrap_servers": [ "a", "b" ] }
|
||||
JSON
|
||||
cat >"$WORK_DIR/verify-bootstrap.json" <<'JSON'
|
||||
{ "main_index": 0, "bootstrap_index": 1, "bootstrap_servers": [ "a", "b" ] }
|
||||
JSON
|
||||
|
||||
verification="$(ucode -L "$PODKOP_LIB" "$FAILOVER" verification-plan-fixture "$WORK_DIR/verify-previous.json" "$WORK_DIR/verify-bootstrap.json")"
|
||||
printf '%s' "$verification" | grep -Eq '"main"[[:space:]]*:[[:space:]]*false' || fail "bootstrap-only switch must not require a dead main DNS to recover"
|
||||
printf '%s' "$verification" | grep -Eq '"bootstrap"[[:space:]]*:[[:space:]]*true' || fail "bootstrap-only switch must verify the selected bootstrap DNS"
|
||||
|
||||
printf 'DNS failover regression checks passed\n'
|
||||
@@ -51,7 +51,7 @@ assert_mark_range_no_overlap "Zapret2" "$((ZAPRET2_ROUTE_MARK_BASE))" "$ZAPRET2_
|
||||
|
||||
cat >"$WORK_DIR/fixture.json" <<'JSON'
|
||||
{
|
||||
"settings": { ".name": "settings", ".type": "settings" }
|
||||
"settings": { ".name": "settings", ".type": "settings", "dns_server": [ "77.88.8.8" ], "bootstrap_dns_server": [ "77.88.8.8" ] }
|
||||
}
|
||||
JSON
|
||||
|
||||
|
||||
@@ -217,8 +217,16 @@ validate_rejects() {
|
||||
local fixture="$1"
|
||||
local expected="$2"
|
||||
local output
|
||||
local normalized="$WORK_DIR/validator-$(basename "$fixture")"
|
||||
node - "$fixture" "$normalized" <<'JS'
|
||||
const fs = require('fs');
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
input.settings.dns_server = ['77.88.8.8'];
|
||||
input.settings.bootstrap_dns_server = ['77.88.8.8'];
|
||||
fs.writeFileSync(process.argv[3], JSON.stringify(input));
|
||||
JS
|
||||
if output="$(PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR_UC" \
|
||||
validate-runtime-fixture "$fixture" '{}' 2>/dev/null)"; then
|
||||
validate-runtime-fixture "$normalized" '{}' 2>/dev/null)"; then
|
||||
fail "validator accepted $fixture"
|
||||
fi
|
||||
printf '%s\n' "$output" | grep -Fq "$expected" ||
|
||||
|
||||
@@ -28,8 +28,18 @@ generate_config() {
|
||||
}
|
||||
|
||||
validate_fixture() {
|
||||
local source="$1"
|
||||
local normalized="$WORK_DIR/validator-$(basename "$source")"
|
||||
node - "$source" "$normalized" <<'JS'
|
||||
const fs = require('fs');
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
input.settings ??= { '.name': 'settings', '.type': 'settings' };
|
||||
input.settings.dns_server ??= ['77.88.8.8'];
|
||||
input.settings.bootstrap_dns_server ??= ['77.88.8.8'];
|
||||
fs.writeFileSync(process.argv[3], JSON.stringify(input));
|
||||
JS
|
||||
PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR_UC" \
|
||||
validate-runtime-fixture "$1" "{}"
|
||||
validate-runtime-fixture "$normalized" "{}"
|
||||
}
|
||||
|
||||
assert_rejects() {
|
||||
|
||||
@@ -400,6 +400,14 @@ tcp
|
||||
9.9.9.9
|
||||
[settings.bootstrap_dns_server]
|
||||
1.0.0.1
|
||||
[settings.dns_check_interval]
|
||||
10s
|
||||
[settings.dns_recovery_check_interval]
|
||||
60s
|
||||
[settings.dns_check_timeout]
|
||||
2s
|
||||
[settings.dns_detour_enabled]
|
||||
0
|
||||
[settings.dns_rewrite_ttl]
|
||||
120
|
||||
[settings.output_network_interface]
|
||||
|
||||
@@ -103,6 +103,14 @@ if (mode == "stop-runtime" || mode == "start-runtime")
|
||||
exit(64);
|
||||
'
|
||||
|
||||
write_stub "$FAKE_LIB/singbox/dns_failover.uc" "$stub_header"'
|
||||
let mode = as_string(ARGV[0]);
|
||||
record("singbox/dns_failover:" + mode);
|
||||
if (mode == "stop-runtime" || mode == "start-runtime")
|
||||
exit(0);
|
||||
exit(64);
|
||||
'
|
||||
|
||||
run_update() {
|
||||
local summary="$1"
|
||||
local log="$2"
|
||||
@@ -141,10 +149,12 @@ const expected = [
|
||||
"server/service:prepare-all-defaults",
|
||||
"config/validator:validate-runtime",
|
||||
"singbox/runtime:configure-service",
|
||||
"singbox/dns_failover:stop-runtime",
|
||||
"singbox/runtime:init-config:0:1:1",
|
||||
"singbox/priority:stop-runtime",
|
||||
"service/state:reload-sing-box-runtime",
|
||||
"singbox/priority:start-runtime",
|
||||
"singbox/dns_failover:start-runtime",
|
||||
"service/state:write-current-reload-state-clean",
|
||||
"service/state:run-pending-reload-if-requested"
|
||||
];
|
||||
@@ -164,7 +174,7 @@ JS
|
||||
unchanged_log="$WORK_DIR/unchanged.log"
|
||||
run_update "0 0 1 0" "$unchanged_log"
|
||||
|
||||
if grep -Eq 'server/service|config/validator|singbox/runtime|singbox/priority|reload-sing-box-runtime|write-current-reload-state-clean' "$unchanged_log"; then
|
||||
if grep -Eq 'server/service|config/validator|singbox/runtime|singbox/priority|singbox/dns_failover|reload-sing-box-runtime|write-current-reload-state-clean' "$unchanged_log"; then
|
||||
fail "unchanged subscription update must not rebuild or reload sing-box"
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user