#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" NFT_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/nft/apply.uc" VALIDATOR_RUNTIME="$ROOT_DIR/tachyon/files/usr/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'" } 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/schedule_fixture.json" <<'JSON' { "section": [ { ".name": "sec_youtube", "enabled": "1", "action": "connection", "ip_cidr": [ "142.250.0.0/15" ] }, { ".name": "sec_discord", "enabled": "1", "action": "connection", "ip_cidr": [ "162.158.0.0/16" ] } ], "schedule": [ { ".name": "phone_night", "enabled": "1", "device_ip": "192.168.1.150", "target": "all", "action": "block", "start_time": "22:00", "end_time": "08:00", "days": [ "mon", "tue", "wed", "thu", "fri" ] }, { ".name": "tv_multi", "enabled": "1", "device_ip": "192.168.1.160", "target": "sections", "sections": [ "sec_youtube", "sec_discord" ], "action": "block", "start_time": "21:00", "end_time": "07:00", "days": [ "sat", "sun" ] }, { ".name": "mac_rule", "enabled": "1", "device_ip": "11:22:33:44:55:66", "target": "all", "action": "block", "start_time": "10:00", "end_time": "12:00" } ] } JSON # Run nft-add-schedule-rules-fixture nft_ucode nft-add-schedule-rules-fixture "$WORK_DIR/schedule_fixture.json" "tachyon" # Verify rules in NFT_LOG assert_contains "$NFT_LOG" "parental_forward ip saddr 192.168.1.150 meta day { Friday, Monday, Thursday, Tuesday, Wednesday } meta hour \"22:00:00\"-\"23:59:59\" counter drop" "midnight wrap part 1" assert_contains "$NFT_LOG" "parental_forward ip saddr 192.168.1.150 meta day { Friday, Monday, Thursday, Tuesday, Wednesday } meta hour \"00:00:00\"-\"08:00:00\" counter drop" "midnight wrap part 2" assert_contains "$NFT_LOG" "parental_control ip saddr 192.168.1.150 meta day { Friday, Monday, Thursday, Tuesday, Wednesday } meta hour \"22:00:00\"-\"23:59:59\" counter drop" "parental_control drop" # Verify MAC address rule in NFT_LOG assert_contains "$NFT_LOG" "parental_forward ether saddr 11:22:33:44:55:66 meta hour \"10:00:00\"-\"12:00:00\" counter drop" "mac ether saddr drop" # Verify per-section blocking assert_contains "$NFT_LOG" "ip saddr 192.168.1.160 meta day { Saturday, Sunday } meta hour \"21:00:00\"-\"23:59:59\" ip daddr @tachyon_rule_sec_youtube_subnets counter drop" "section youtube subnet drop" assert_contains "$NFT_LOG" "ip saddr 192.168.1.160 meta day { Saturday, Sunday } meta hour \"21:00:00\"-\"23:59:59\" ip daddr @tachyon_rule_sec_discord_subnets counter drop" "section discord subnet drop" # Verify signature changes when schedule is modified SIG1="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/schedule_fixture.json")" cat >"$WORK_DIR/schedule_fixture2.json" <<'JSON' { "section": [ { ".name": "sec_youtube", "enabled": "1", "action": "connection" } ], "schedule": [ { ".name": "phone_night", "enabled": "0", "device_ip": "192.168.1.150", "target": "all" } ] } JSON SIG2="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/schedule_fixture2.json")" [ "$SIG1" != "$SIG2" ] || fail "Signature must change when schedule is updated" # Verify validator rejects invalid schedule time cat >"$WORK_DIR/invalid_time.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_time", "enabled": "1", "device_ip": "192.168.1.100", "start_time": "25:00" } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_time.json" "{}" >/dev/null 2>&1; then fail "Validator must reject invalid start_time 25:00" fi # Verify validator rejects invalid schedule device IP cat >"$WORK_DIR/invalid_ip.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_ip", "enabled": "1", "device_ip": "999.999.999.999", "start_time": "22:00", "end_time": "08:00" } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_ip.json" "{}" >/dev/null 2>&1; then fail "Validator must reject invalid device_ip 999.999.999.999" fi # Verify validator rejects unknown target section cat >"$WORK_DIR/invalid_target.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_target", "enabled": "1", "device_ip": "192.168.1.100", "target": "non_existent_section" } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_target.json" "{}" >/dev/null 2>&1; then fail "Validator must reject non-existent target section" fi # Verify validator rejects unknown profile reference in schedule cat >"$WORK_DIR/invalid_profile_ref.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "schedule": [ { ".name": "bad_profile_ref", "enabled": "1", "profile": [ "non_existent_profile" ] } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_profile_ref.json" "{}" >/dev/null 2>&1; then fail "Validator must reject non-existent profile reference" fi # Verify validator rejects invalid profile device IP cat >"$WORK_DIR/invalid_profile_ip.json" <<'JSON' { "settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] }, "profile": [ { ".name": "bad_profile", "enabled": "1", "device_ip": [ "invalid-ip-or-mac" ] } ] } JSON if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_profile_ip.json" "{}" >/dev/null 2>&1; then fail "Validator must reject invalid profile device IP" fi # ─── Family Profile inheritance fixture ────────────────────────────────────── cat >"$WORK_DIR/profile_fixture.json" <<'JSON' { "profile": [ { ".name": "prof_kids", "label": "Kids", "avatar": "👶", "enabled": "1", "device_ip": [ "192.168.1.200", "22:33:44:55:66:77" ], "block_doh": "1", "safe_search": "1", "daily_quota_minutes": "90" } ], "schedule": [ { ".name": "kids_schedule", "enabled": "1", "profile": [ "prof_kids" ], "target": "all", "action": "block", "start_time": "23:00", "end_time": "06:00" } ] } JSON rm -f "$NFT_LOG" nft_ucode nft-add-schedule-rules-fixture "$WORK_DIR/profile_fixture.json" "tachyon" # Schedule must inherit devices from prof_kids assert_contains "$NFT_LOG" "parental_forward ip saddr 192.168.1.200 meta hour \"23:00:00\"-\"23:59:59\" counter drop" "profile IP inherited" assert_contains "$NFT_LOG" "parental_forward ether saddr 22:33:44:55:66:77 meta hour \"23:00:00\"-\"23:59:59\" counter drop" "profile MAC inherited" # ─── Router panel and Intranet bypass rules in Parental chains ─────────────── rm -f "$NFT_LOG" nft_ucode nft-create-runtime-base TachyonTable localv4 tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces "br-lan" 0x00100000 0x00200000 198.18.0.0/15 1602 0 "" "" "" "" "" 0 assert_contains "$NFT_LOG" "parental_control ip daddr @localv4 return" "parental_control localv4 bypass" assert_contains "$NFT_LOG" "parental_control ip6 daddr @localv6 return" "parental_control localv6 bypass" assert_contains "$NFT_LOG" "parental_forward ip daddr @localv4 return" "parental_forward localv4 bypass" assert_contains "$NFT_LOG" "parental_forward ip6 daddr @localv6 return" "parental_forward localv6 bypass" # Ensure in mangle chain that localv4 return comes BEFORE jump parental_control local_return_line="$(grep -n "mangle iifname @tachyon_interfaces ip daddr @localv4 return" "$NFT_LOG" | cut -d: -f1)" parental_jump_line="$(grep -n "mangle jump parental_control" "$NFT_LOG" | cut -d: -f1)" [ -n "$local_return_line" ] || fail "mangle chain must contain localv4 return rule" [ -n "$parental_jump_line" ] || fail "mangle chain must contain jump parental_control rule" if [ "$local_return_line" -ge "$parental_jump_line" ]; then fail "localv4 return (line $local_return_line) must precede jump parental_control (line $parental_jump_line) to protect router panel access" fi # ─── Domain-based schedule must NOT add global counter drop rules ──────────── cat >"$WORK_DIR/domain_schedule_fixture.json" <<'JSON' { "schedule": [ { ".name": "domain_only_sched", "enabled": "1", "device_ip": "192.168.1.180", "target": "all", "blocked_domains": [ "tiktok.com", "roblox.com" ], "action": "block" }, { ".name": "target_domains_sched", "enabled": "1", "device_ip": "192.168.1.181", "target": "domains", "action": "block" } ] } JSON rm -f "$NFT_LOG" touch "$NFT_LOG" nft_ucode nft-add-schedule-rules-fixture "$WORK_DIR/domain_schedule_fixture.json" "tachyon" if grep -Eq "parental_(forward|control).*192\.168\.1\.180.*counter.*drop" "$NFT_LOG"; then fail "Schedule with blocked_domains must NOT add global counter drop in parental_forward/parental_control" fi if grep -Eq "parental_(forward|control).*192\.168\.1\.181.*counter.*drop" "$NFT_LOG"; then fail "Schedule with target=domains must NOT add global counter drop in parental_forward/parental_control" fi # ─── 24/7 Schedule referencing Profile with daily_quota_minutes must NOT drop statically cat >"$WORK_DIR/quota_schedule_fixture.json" <<'JSON' { "profile": [ { ".name": "prof_quota_only", "enabled": "1", "device_ip": [ "192.168.1.222" ], "daily_quota_minutes": "120" } ], "schedule": [ { ".name": "quota_24_7_sched", "enabled": "1", "profile": [ "prof_quota_only" ], "target": "all", "action": "block" } ] } JSON rm -f "$NFT_LOG" touch "$NFT_LOG" nft_ucode nft-add-schedule-rules-fixture "$WORK_DIR/quota_schedule_fixture.json" "tachyon" if grep -Eq "parental_(forward|control).*192\.168\.1\.222.*counter.*drop" "$NFT_LOG"; then fail "24/7 Schedule with profile daily_quota_minutes must NOT add static counter drop" fi # ─── Test: Parental Quota tick unblocks device when quota is increased ──────── PARENTAL_QUOTA="$TACHYON_LIB/service/parental_quota.uc" QUOTA_STATE="$WORK_DIR/parental_quotas.json" MOCK_UCI_STATE="$WORK_DIR/uci_quota_state.json" cat >"$WORK_DIR/bin/logger" <<'EOF' #!/usr/bin/env bash exit 0 EOF chmod +x "$WORK_DIR/bin/logger" cat >"$WORK_DIR/bin/ip" <<'EOF' #!/usr/bin/env bash if [ "$1" = "neigh" ] && [ "$2" = "show" ]; then echo "192.168.1.249 dev br-lan lladdr 00:11:22:33:44:55 REACHABLE" fi exit 0 EOF chmod +x "$WORK_DIR/bin/ip" # 1. State where device was previously blocked at 5 minutes cat >"$QUOTA_STATE" <<'JSON' { "date": "2026-09-12", "devices": { "192.168.1.249": { "minutes": 5, "blocked": true } }, "guest_devices": {} } JSON # Quota increased to 60 minutes in schedule (target: all) cat >"$MOCK_UCI_STATE" <<'JSON' { "schedule": [ { ".name": "kids_quota", "enabled": "1", "device_ip": [ "192.168.1.249" ], "target": "all", "daily_quota_minutes": "60" } ] } JSON rm -f "$NFT_LOG" touch "$NFT_LOG" PARENTAL_QUOTA_STATE_FILE="$QUOTA_STATE" TACHYON_UCI_STATE_FILE="$MOCK_UCI_STATE" \ ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" tick # Device must now be unblocked in state grep -Eq '"192\.168\.1\.249":\s*\{[^}]*"blocked":\s*false' "$QUOTA_STATE" || \ fail "Device 192.168.1.249 should be unblocked after daily_quota_minutes increased to 60" # Verify nftables unblock (set flushed and device not re-added) was executed assert_contains "$NFT_LOG" "flush set inet TachyonTable tachyon_quota_block_ip" "nft flush set for unblocked device" if grep -q "add.*tachyon_quota_block_ip.*192\.168\.1\.249" "$NFT_LOG"; then fail "192.168.1.249 should not be in tachyon_quota_block_ip after quota increase" fi # 2. Schedule with target=domains must NOT be treated as a network-wide quota in parental_quota.uc cat >"$MOCK_UCI_STATE" <<'JSON' { "schedule": [ { ".name": "domain_sched", "enabled": "1", "device_ip": [ "192.168.1.249" ], "target": "domains", "blocked_domains": [ "youtube.com" ], "daily_quota_minutes": "5" } ] } JSON # Initial clean state with 0 minutes cat >"$QUOTA_STATE" <<'JSON' { "date": "2026-09-12", "devices": {}, "guest_devices": {} } JSON PARENTAL_QUOTA_STATE_FILE="$QUOTA_STATE" TACHYON_UCI_STATE_FILE="$MOCK_UCI_STATE" \ ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" tick # Schedule with target=domains must not have been added to devices or blocked if grep -q '"192\.168\.1\.249"' "$QUOTA_STATE"; then fail "Schedule with target=domains must NOT track device screen-time quota" fi # 3. Disabling all rules must unblock previously blocked devices and flush nft set cat >"$QUOTA_STATE" <<'JSON' { "date": "2026-09-12", "devices": { "192.168.1.249": { "minutes": 60, "blocked": true } }, "guest_devices": {} } JSON # Empty/disabled UCI rules cat >"$MOCK_UCI_STATE" <<'JSON' { "schedule": [] } JSON rm -f "$NFT_LOG" touch "$NFT_LOG" PARENTAL_QUOTA_STATE_FILE="$QUOTA_STATE" TACHYON_UCI_STATE_FILE="$MOCK_UCI_STATE" \ ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" tick # Device must now be unblocked in state grep -Eq '"192\.168\.1\.249":\s*\{[^}]*"blocked":\s*false' "$QUOTA_STATE" || \ fail "Device 192.168.1.249 should be unblocked when all rules are disabled/removed" assert_contains "$NFT_LOG" "flush set inet TachyonTable tachyon_quota_block_ip" "nft flush set when rule removed" if grep -q "add.*tachyon_quota_block_ip.*192\.168\.1\.249" "$NFT_LOG"; then fail "192.168.1.249 should not be added to block set when rules removed" fi printf 'Parental control, profiles and schedule tests passed\n'