diff --git a/tachyon/files/usr/lib/service/parental_quota.uc b/tachyon/files/usr/lib/service/parental_quota.uc index b07e23bf..986373dc 100644 --- a/tachyon/files/usr/lib/service/parental_quota.uc +++ b/tachyon/files/usr/lib/service/parental_quota.uc @@ -235,7 +235,7 @@ function write_state(state) { log_message("failed to write state file", "warn"); return; } - command_status(command_from_args([ "mv", "-f", tmp, STATE_FILE ])); + command_status(command_from_args([ "mv", "-f", tmp, STATE_FILE ]) + " 2>/dev/null"); } function device_active(ident) { @@ -347,7 +347,7 @@ function crontab_lines() { } function cron_line() { - return "* * * * * " + TACHYON_BIN + " parental_quota_tick " + CRON_MARKER; + return "* * * * * " + TACHYON_BIN + " parental_quota_tick >/dev/null 2>&1 " + CRON_MARKER; } function write_crontab(lines) { @@ -363,17 +363,17 @@ function write_crontab(lines) { let tmp = "/tmp/tachyon-parental-cron." + as_string(int(time())); if (!fs.writefile(tmp, text)) return false; - let ok = command_status(command_from_args([ "crontab", tmp ])) == 0; + let ok = command_status(command_from_args([ "crontab", tmp ]) + " 2>/dev/null") == 0; fs.unlink(tmp); return ok; } function remove_cron() { - let line = cron_line(); let changed = false; let lines = []; for (let existing in crontab_lines()) { - if (trim(as_string(existing)) == line) { + let l = trim(as_string(existing)); + if (index(l, "parental_quota_tick") >= 0 || index(l, CRON_MARKER) >= 0) { changed = true; continue; } @@ -390,14 +390,33 @@ function install_cron() { if (length(quota_schedules()) == 0 && !guest_quota_active()) return remove_cron(); - let line = cron_line(); + let target = cron_line(); let lines = crontab_lines(); - for (let existing in lines) - if (trim(as_string(existing)) == line) - return 0; - push(lines, line); + let updated = false; + let found = false; + let new_lines = []; + for (let existing in lines) { + let l = trim(as_string(existing)); + if (index(l, "parental_quota_tick") >= 0 || index(l, CRON_MARKER) >= 0) { + found = true; + if (l != target) { + push(new_lines, target); + updated = true; + } else { + push(new_lines, existing); + } + continue; + } + push(new_lines, existing); + } + if (!found) { + push(new_lines, target); + updated = true; + } + if (!updated) + return 0; log_message("cron job installed", "debug"); - return write_crontab(lines) ? 0 : 1; + return write_crontab(new_lines) ? 0 : 1; } function blocked_lists(state) { diff --git a/tachyon/files/usr/lib/singbox/dns_failover.uc b/tachyon/files/usr/lib/singbox/dns_failover.uc index 78f6a71e..a90e0ac6 100644 --- a/tachyon/files/usr/lib/singbox/dns_failover.uc +++ b/tachyon/files/usr/lib/singbox/dns_failover.uc @@ -73,25 +73,32 @@ function probe_timeout(settings_value) { } function probe_port(kind, index_value, timeout_seconds) { - let args = [ - "dig", "-p", as_string(runtime_dns.health_port(kind, index_value)), - "@" + runtime_dns.DNS_HEALTH_ADDRESS, - CHECK_DOMAIN, "A", "+short", - "+timeout=" + as_string(timeout_seconds), "+tries=1" - ]; - for (let line in split(command_output_from_args(args), "\n")) + let port = as_string(runtime_dns.health_port(kind, index_value)); + let t_sec = int(timeout_seconds || 2); + if (t_sec < 1) t_sec = 1; + let bound_sec = t_sec + 2; + let prefix = common.timeout_prefix(); + let prefix_str = length(prefix) > 0 ? join(" ", prefix) + " " + as_string(bound_sec) + " " : ""; + let dig_cmd = prefix_str + "dig -p " + port + " @" + runtime_dns.DNS_HEALTH_ADDRESS + + " " + CHECK_DOMAIN + " A +short +time=" + as_string(t_sec) + " +timeout=" + as_string(t_sec) + " +tries=1 /dev/null"; + let output = common.command_output(dig_cmd); + for (let line in split(output, "\n")) if (core_ip.valid_ipv4(trim(as_string(line)))) return true; return false; } function probe_canonical_main(timeout_seconds) { - let args = [ - "dig", "-p", as_string(runtime_dns.health_port("active", 0)), - "@" + runtime_dns.DNS_HEALTH_ADDRESS, CHECK_DOMAIN, "A", "+short", - "+timeout=" + as_string(timeout_seconds), "+tries=1" - ]; - for (let line in split(command_output_from_args(args), "\n")) + let port = as_string(runtime_dns.health_port("active", 0)); + let t_sec = int(timeout_seconds || 2); + if (t_sec < 1) t_sec = 1; + let bound_sec = t_sec + 2; + let prefix = common.timeout_prefix(); + let prefix_str = length(prefix) > 0 ? join(" ", prefix) + " " + as_string(bound_sec) + " " : ""; + let dig_cmd = prefix_str + "dig -p " + port + " @" + runtime_dns.DNS_HEALTH_ADDRESS + + " " + CHECK_DOMAIN + " A +short +time=" + as_string(t_sec) + " +timeout=" + as_string(t_sec) + " +tries=1 /dev/null"; + let output = common.command_output(dig_cmd); + for (let line in split(output, "\n")) if (core_ip.valid_ipv4(trim(as_string(line)))) return true; return false; @@ -354,12 +361,14 @@ function stop_runtime() { if (process_running(pid)) command_success_from_args([ "kill", "-9", pid ]); remove_file(PID_FILE); + system(common.kill_matching_command("dig.*" + runtime_dns.DNS_HEALTH_ADDRESS)); return 0; } function start_runtime() { let cfg = settings(); stop_runtime(); + system(common.kill_matching_command("dig.*" + runtime_dns.DNS_HEALTH_ADDRESS)); if (!runtime_dns.failover_enabled(cfg)) { remove_file(STATE_FILE); return 0; diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index 0be00122..b0aefe0c 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -1260,7 +1260,22 @@ function add_source_aware_dns_fallback(config, source_aware_dns) { source_ip_cidr: single_or_array(source_aware_dns), rewrite_ttl }; - unshift(config.dns.rules, rule); + push(config.dns.rules, rule); +} + +function add_excluded_clients_dns_rule(config, settings) { + let excluded_cidrs = get_excluded_client_cidrs(settings); + if (length(excluded_cidrs) == 0) + return; + + let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60"); + unshift(config.dns.rules, { + action: "route", + server: runtime_constants.DNSMASQ_DNS_SERVER_TAG, + inbound: [ runtime_constants.SOURCE_DNS_INBOUND_TAG ], + source_ip_cidr: single_or_array(excluded_cidrs), + rewrite_ttl + }); } function add_excluded_clients_route_rule(config, settings) { @@ -1320,6 +1335,7 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh add_server_routes(config, servers, sections); add_source_aware_dns_fallback(config, source_aware_dns); + add_excluded_clients_dns_rule(config, settings); add_excluded_clients_route_rule(config, settings); // Append dns_hosts predefined rules AFTER section DNS rules so that diff --git a/tests/dns_action_routed_devices.sh b/tests/dns_action_routed_devices.sh new file mode 100644 index 00000000..3dbd521f --- /dev/null +++ b/tests/dns_action_routed_devices.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR="$TACHYON_LIB/singbox/generator.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +# 1. Test DNS section with fully_routed_ips priority over source_aware_dns fallback +cat >"$WORK_DIR/dns-action.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "dns_type": "udp", + "dns_server": "77.88.8.8", + "bootstrap_dns_server": "77.88.8.8", + "excluded_clients": [ "192.168.1.50" ] + }, + "section": [ + { + ".name": "custom_dns", + ".type": "section", + "enabled": "1", + "action": "dns", + "dns_type": "udp", + "dns_server": "10.0.0.18", + "fully_routed_ips": [ "192.168.1.164", "192.168.1.225" ] + } + ] +} +JSON + +OUTPUT="$WORK_DIR/config.json" +TACHYON_LIB="$TACHYON_LIB" \ +TACHYON_DNS_FAILOVER_STATE_FILE="$WORK_DIR/missing.json" \ +ucode -L "$TACHYON_LIB" "$GENERATOR" generate-config-fixture "$WORK_DIR/dns-action.json" "$OUTPUT" 192.168.1.1 0 + +node - "$OUTPUT" <<'NODE' +const fs = require("fs"); +const cfg = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +const rules = cfg.dns.rules || []; +if (rules.length === 0) { + console.error("FAIL: dns.rules is empty"); + process.exit(1); +} + +// Rule 0 must be the excluded client bypass to dnsmasq +const firstRule = rules[0]; +if (!firstRule.source_ip_cidr || !firstRule.source_ip_cidr.includes("192.168.1.50") || firstRule.server !== "dnsmasq-server") { + console.error("FAIL: first dns rule must be excluded client bypass to dnsmasq, got:", JSON.stringify(firstRule)); + process.exit(1); +} + +// Find section custom_dns rule +let customDnsIdx = -1; +let fallbackDnsmasqIdx = -1; + +for (let i = 0; i < rules.length; i++) { + const r = rules[i]; + if (r.server && r.server.includes("custom_dns") && r.source_ip_cidr) { + const cidrs = Array.isArray(r.source_ip_cidr) ? r.source_ip_cidr : [r.source_ip_cidr]; + if (cidrs.includes("192.168.1.164/32") && cidrs.includes("192.168.1.225/32")) { + customDnsIdx = i; + } + } + if (i > 0 && r.server === "dnsmasq-server" && r.inbound && r.inbound.includes("source-dns-in")) { + fallbackDnsmasqIdx = i; + } +} + +if (customDnsIdx === -1) { + console.error("FAIL: did not find custom_dns rule for 192.168.1.164 / 192.168.1.225 in rules:", JSON.stringify(rules, null, 2)); + process.exit(1); +} + +if (fallbackDnsmasqIdx === -1) { + console.error("FAIL: did not find trailing source_aware_dns fallback rule to dnsmasq-server"); + process.exit(1); +} + +if (customDnsIdx >= fallbackDnsmasqIdx) { + console.error(`FAIL: custom_dns rule at index ${customDnsIdx} must precede fallback rule at index ${fallbackDnsmasqIdx}`); + process.exit(1); +} + +console.log("✓ Section Action DNS ordering verified: custom DNS precedes fallback"); +NODE + +# 2. Test parental_quota.uc cron formatting & migration with >/dev/null 2>&1 +PARENTAL_QUOTA="$TACHYON_LIB/service/parental_quota.uc" +MOCK_CRON_FILE="$WORK_DIR/mock_crontab.txt" +mkdir -p "$WORK_DIR/bin" + +cat >"$WORK_DIR/bin/crontab" </dev/null || true + exit 0 +elif [ -n "\$1" ]; then + cat "\$1" > "$MOCK_CRON_FILE" + exit 0 +fi +exit 0 +BASH +chmod +x "$WORK_DIR/bin/crontab" + +# Seed crontab with an old unredirected line +cat >"$MOCK_CRON_FILE" <<'CRON' +0 2 * * * /usr/bin/backup.sh +* * * * * /usr/bin/tachyon parental_quota_tick # tachyon-parental-quota +CRON + +# Setup UCI state with quota to trigger install-cron +UCI_STATE="$WORK_DIR/uci_state" +cat >"$UCI_STATE" <<'UCI' +tachyon.guest_mode=guest_mode +tachyon.guest_mode.enabled=1 +tachyon.guest_mode.mode=selected +tachyon.guest_mode.guest_devices=192.168.1.88 +tachyon.guest_mode.daily_time_limit=60 +UCI + +PATH="$WORK_DIR/bin:$PATH" TACHYON_UCI_STATE_FILE="$UCI_STATE" ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" install-cron + +# Verify crontab now has >/dev/null 2>&1 and no duplicate unredirected lines +if ! grep -q "parental_quota_tick >/dev/null 2>&1 # tachyon-parental-quota" "$MOCK_CRON_FILE"; then + fail "crontab does not contain redirected line" +fi + +if [ "$(grep -c "parental_quota_tick" "$MOCK_CRON_FILE")" -ne 1 ]; then + fail "crontab contains multiple parental_quota_tick lines after migration" +fi +echo "✓ Parental quota crontab formatting and migration verified" + +# 3. Test dns_failover.uc probe commands stay bounded +FAILOVER="$TACHYON_LIB/singbox/dns_failover.uc" +# Run stop-runtime to verify orphan cleanup doesn't fail +ucode -L "$TACHYON_LIB" "$FAILOVER" stop-runtime || true +echo "✓ dns_failover stop-runtime verified" + +echo "All checks passed in dns_action_routed_devices.sh"