#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" NFT_RUNTIME="$TACHYON_LIB/nft/apply.uc" VALIDATOR_RUNTIME="$TACHYON_LIB/config/validator.uc" WORK_DIR="$(mktemp -d)" NFT_LOG="$WORK_DIR/nft.log" nft_ucode() { ucode -L "$TACHYON_LIB" "$NFT_RUNTIME" "$@" } cleanup() { rm -rf "$WORK_DIR" } trap cleanup EXIT fail() { printf 'FAIL: %s\n' "$1" >&2 cat "$NFT_LOG" >&2 2>/dev/null || true exit 1 } assert_contains() { local file="$1" local expected="$2" local label="$3" grep -Fq "$expected" "$file" || fail "$label: expected '$expected'" } generate_config() { local fixture="$1" local output="$2" mkdir -p "$output.section-cache" "$output.rulesets" ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ "$fixture" "$output" "127.0.0.1" "0" } # ─── Fixture: always-on block with IP device ───────────────────────────────── cat >"$WORK_DIR/fixture_always_on.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "config_path": "/tmp/sing-box/config.json", "dns_server": "1.1.1.1", "service_listen_address": "127.0.0.1" }, "section": [ { ".name": "sec_proxy", "enabled": "1", "action": "connection", "label": "Main proxy", "selector_proxy_links": [ "socks5://127.0.0.1:1080#Proxy" ] } ], "schedule": [ { ".name": "kids_youtube", "enabled": "1", "label": "Kids YouTube", "device_ip": [ "192.168.1.150" ], "blocked_domains": [ "youtube.com", "googlevideo.com" ], "mode": "block", "dns_level": "1", "notify": "1" } ] } JSON OUT="$WORK_DIR/always_on.json" generate_config "$WORK_DIR/fixture_always_on.json" "$OUT" # dns-block-in inbound must exist assert_contains "$OUT" '"tag": "dns-block-in"' "always-on: dns-block-in inbound" assert_contains "$OUT" '"listen_port": 1053' "always-on: block inbound port 1053" # Hijack DNS route rule for dns-block-in assert_contains "$OUT" '"action": "hijack-dns"' "always-on: hijack-dns route rule" # DNS reject rules with domain_suffix and source_ip_cidr for IP devices assert_contains "$OUT" '"domain_suffix": [' "always-on: domain_suffix matching" grep -q '192.168.1.150/32' "$OUT" || fail "always-on: DNS rule must carry source_ip_cidr 192.168.1.150/32" assert_contains "$OUT" '"action": "reject"' "always-on: DNS reject action" # Route rule fallback (always-on schedule, IP device) grep -o '"action": "reject"' "$OUT" | head -2 >/dev/null || fail "always-on: route reject rule missing" # ─── Fixture: whitelist (allow only) mode ──────────────────────────────────── cat >"$WORK_DIR/fixture_allow.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "config_path": "/tmp/sing-box/config.json", "dns_server": "1.1.1.1", "service_listen_address": "127.0.0.1" }, "section": [ { ".name": "sec_proxy", "enabled": "1", "action": "connection", "label": "Main proxy", "selector_proxy_links": [ "socks5://127.0.0.1:1080#Proxy" ] } ], "schedule": [ { ".name": "kids_whitelist", "enabled": "1", "label": "Kids Whitelist", "device_ip": [ "192.168.1.151" ], "blocked_domains": [ "school.edu", "khanacademy.org" ], "mode": "allow", "dns_level": "1", "notify": "0" } ] } JSON OUT="$WORK_DIR/allow.json" generate_config "$WORK_DIR/fixture_allow.json" "$OUT" # Whitelist mode must invert the DNS rule grep -o '"invert": true' "$OUT" | head -1 >/dev/null || fail "whitelist: DNS rule must be inverted" assert_contains "$OUT" '192.168.1.151/32' "whitelist: source_ip_cidr scoped" # ─── Fixture: MAC-only device (no IP in DNS rules, nft redirect only) ──────── cat >"$WORK_DIR/fixture_mac.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "config_path": "/tmp/sing-box/config.json", "dns_server": "1.1.1.1", "service_listen_address": "127.0.0.1" }, "section": [ { ".name": "sec_proxy", "enabled": "1", "action": "connection", "label": "Main proxy", "selector_proxy_links": [ "socks5://127.0.0.1:1080#Proxy" ] } ], "schedule": [ { ".name": "tv_block", "enabled": "1", "label": "TV Block", "device_ip": [ "11:22:33:44:55:66" ], "blocked_domains": [ "netflix.com" ], "mode": "block", "dns_level": "1", "notify": "0" } ] } JSON OUT="$WORK_DIR/mac.json" generate_config "$WORK_DIR/fixture_mac.json" "$OUT" # MAC-only device: DNS rules must NOT carry source_ip_cidr (no IP known) if grep -o '"source_ip_cidr"' "$OUT" | head -1 >/dev/null; then fail "mac-only: DNS rule must not carry source_ip_cidr for MAC-only device" fi # But dns-block-in must still exist assert_contains "$OUT" '"tag": "dns-block-in"' "mac-only: dns-block-in inbound" # ─── nftables DNS redirect rules ───────────────────────────────────────────── mkdir -p "$WORK_DIR/bin" cat >"$WORK_DIR/bin/nft" <>"$NFT_LOG" exit 0 NFT chmod +x "$WORK_DIR/bin/nft" export PATH="$WORK_DIR/bin:$PATH" cat >"$WORK_DIR/nft_fixture.json" <<'JSON' { "schedule": [ { ".name": "kids_youtube", "enabled": "1", "label": "Kids YouTube", "device_ip": [ "192.168.1.150", "11:22:33:44:55:66" ], "blocked_domains": [ "youtube.com" ], "start_time": "22:00", "end_time": "08:00", "mode": "block", "dns_level": "1", "notify": "1" }, { ".name": "tv_always", "enabled": "1", "label": "TV Always", "device_ip": [ "192.168.1.160" ], "blocked_domains": [ "netflix.com" ], "mode": "block", "dns_level": "1", "notify": "0" } ] } JSON nft_ucode nft-add-dns-block-rules-fixture "$WORK_DIR/nft_fixture.json" "tachyon" # Time-gated rule: IP device, meta hour window, redirect to block target assert_contains "$NFT_LOG" "dns_block ip saddr 192.168.1.150 meta hour \"22:00:00\"-\"23:59:59\" udp dport 53 counter redirect to :1053 comment" "time-gated udp redirect" assert_contains "$NFT_LOG" "dns_block ip saddr 192.168.1.150 meta hour \"00:00:00\"-\"08:00:00\" udp dport 53 counter redirect to :1053" "midnight wrap udp redirect" assert_contains "$NFT_LOG" "tcp dport 53 counter redirect to :1053" "tcp dns redirect" # MAC device redirect assert_contains "$NFT_LOG" "dns_block ether saddr 11:22:33:44:55:66 meta hour" "mac redirect with time" # Always-on schedule: no meta hour matcher if grep -Fq "192.168.1.160 meta hour" "$NFT_LOG"; then fail "always-on schedule must not carry meta hour matcher" fi assert_contains "$NFT_LOG" "ip saddr 192.168.1.160 udp dport 53 counter redirect to :1053" "always-on redirect" # ─── Validator: invalid blocked domain rejected ────────────────────────────── cat >"$WORK_DIR/invalid_domain.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_domain", "enabled": "1", "device_ip": "192.168.1.100", "blocked_domains": [ "not a domain!!" ] } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_domain.json" "{}" >/dev/null 2>&1; then fail "Validator must reject invalid blocked_domains" fi # ─── Validator: invalid mode rejected ──────────────────────────────────────── cat >"$WORK_DIR/invalid_mode.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_mode", "enabled": "1", "device_ip": "192.168.1.100", "blocked_domains": [ "youtube.com" ], "mode": "banana" } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_mode.json" "{}" >/dev/null 2>&1; then fail "Validator must reject invalid mode" fi # ─── Validator: invalid dns_level rejected ─────────────────────────────────── cat >"$WORK_DIR/invalid_dns_level.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_level", "enabled": "1", "device_ip": "192.168.1.100", "blocked_domains": [ "youtube.com" ], "dns_level": "5" } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_dns_level.json" "{}" >/dev/null 2>&1; then fail "Validator must reject invalid dns_level" fi # ─── Signature changes when blocked_domains change ─────────────────────────── SIG1="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/nft_fixture.json")" cat >"$WORK_DIR/nft_fixture2.json" <<'JSON' { "schedule": [ { ".name": "kids_youtube", "enabled": "1", "label": "Kids YouTube", "device_ip": [ "192.168.1.150" ], "blocked_domains": [ "tiktok.com" ], "start_time": "22:00", "end_time": "08:00", "mode": "block", "dns_level": "1", "notify": "1" } ] } JSON SIG2="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/nft_fixture2.json")" [ "$SIG1" != "$SIG2" ] || fail "Signature must change when blocked_domains are updated" # ─── Fixture: Family Profile SafeSearch & DoH Block ────────────────────────── cat >"$WORK_DIR/fixture_profile_safesearch.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "config_path": "/tmp/sing-box/config.json", "dns_server": "1.1.1.1", "service_listen_address": "127.0.0.1" }, "section": [ { ".name": "sec_proxy", "enabled": "1", "action": "connection", "label": "Main proxy", "selector_proxy_links": [ "socks5://127.0.0.1:1080#Proxy" ] } ], "profile": [ { ".name": "prof_teens", "label": "Teens", "enabled": "1", "device_ip": [ "192.168.1.180" ], "safe_search": "1", "block_doh": "1", "blocked_domains": [ "roblox.com" ] } ] } JSON OUT_PROF="$WORK_DIR/profile_safesearch.json" generate_config "$WORK_DIR/fixture_profile_safesearch.json" "$OUT_PROF" # SafeSearch DNS rules must be present assert_contains "$OUT_PROF" '"action": "predefined"' "profile: SafeSearch predefined DNS action" assert_contains "$OUT_PROF" '216.239.38.120' "profile: SafeSearch google/youtube IP" assert_contains "$OUT_PROF" '213.180.193.56' "profile: SafeSearch yandex IP" assert_contains "$OUT_PROF" '192.168.1.180/32' "profile: SafeSearch scoped to profile device" # Profile blocked domains must be present in DNS rules assert_contains "$OUT_PROF" 'roblox.com' "profile: roblox.com blocked" # ─── Schedule with blocked_domains AND daily_quota_minutes must generate DNS block rules ─── cat >"$WORK_DIR/fixture_quota_domains.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "config_path": "/tmp/sing-box/config.json", "dns_server": "1.1.1.1", "service_listen_address": "127.0.0.1" }, "section": [ { ".name": "sec_proxy", "enabled": "1", "action": "connection", "label": "Main proxy", "selector_proxy_links": [ "socks5://127.0.0.1:1080#Proxy" ] } ], "schedule": [ { ".name": "quota_with_domains", "enabled": "1", "device_ip": [ "192.168.1.249" ], "target": "domains", "blocked_domains": [ "tiktok.com" ], "daily_quota_minutes": "5" } ] } JSON OUT_QD="$WORK_DIR/out_quota_domains.json" generate_config "$WORK_DIR/fixture_quota_domains.json" "$OUT_QD" assert_contains "$OUT_QD" '"tag": "dns-block-in"' "quota+domains: dns-block-in inbound" assert_contains "$OUT_QD" 'tiktok.com' "quota+domains: tiktok.com must be in DNS block rules" assert_contains "$OUT_QD" '192.168.1.249/32' "quota+domains: 192.168.1.249 must be scoped" rm -f "$NFT_LOG" touch "$NFT_LOG" nft_ucode nft-add-dns-block-rules-fixture "$WORK_DIR/fixture_quota_domains.json" "tachyon" assert_contains "$NFT_LOG" "192.168.1.249" "quota+domains: nftables DNS redirect rule must be generated" printf 'Content blocking and family profile checks passed\n'