diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js index 1be9c074..762ce82c 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js @@ -1214,13 +1214,14 @@ function createParentalContent(section) { "daily_quota_minutes", _("Daily time quota (minutes)"), _( - "Limit device network usage per day. When the quota is exhausted, the device is blocked until midnight. 0 disables the limit.", + "Limit device network usage per day. When the quota is exhausted, internet access is blocked until midnight. 0 disables the limit.", ), ); o.modalonly = true; o.rmempty = true; o.default = "0"; o.placeholder = "0"; + o.depends("target", "all"); o.validate = function (_sectionId, value) { if (value === "" || value === null || value === undefined) return true; const n = Number(value); diff --git a/tachyon/files/usr/lib/nft/apply.uc b/tachyon/files/usr/lib/nft/apply.uc index 97eaee5a..611587c1 100644 --- a/tachyon/files/usr/lib/nft/apply.uc +++ b/tachyon/files/usr/lib/nft/apply.uc @@ -1182,9 +1182,6 @@ function nft_add_dns_block_rules_from_schedules(schedules, table, profiles) { let intervals = nft_schedule_time_intervals(start_time, end_time); let days_args = nft_schedule_days_match_args(schedule); let always_on = length(intervals) == 0; - let quota_minutes = resolve_schedule_quota_minutes(schedule, profiles); - if (always_on && quota_minutes > 0) - continue; for (let raw_ip in raw_ips) { let dev_str = trim(as_string(raw_ip)); diff --git a/tachyon/files/usr/lib/service/lifecycle.uc b/tachyon/files/usr/lib/service/lifecycle.uc index 865564c4..d57026f0 100644 --- a/tachyon/files/usr/lib/service/lifecycle.uc +++ b/tachyon/files/usr/lib/service/lifecycle.uc @@ -941,6 +941,8 @@ function start_main() { module_success(ZAPRET2_UC, [ "start-runtime" ]); module_success(WDTT_UC, [ "start-runtime" ]); module_success(OLCRTC_UC, [ "start-runtime" ]); + module_success(PARENTAL_QUOTA_UC, [ "install-cron" ]); + module_success(PARENTAL_QUOTA_UC, [ "tick" ]); module_background(UI_UC, [ "latency-boot-sweep" ]); module_background(UPDATES_UC, [ "list-update" ]); @@ -1535,6 +1537,7 @@ function reload(reason) { module_success(TAILSCALE_UC, [ "start-runtime" ]); // Minute-tick cron for parental daily time quotas (idempotent install). module_success(PARENTAL_QUOTA_UC, [ "install-cron" ]); + module_success(PARENTAL_QUOTA_UC, [ "tick" ]); if (plan.needs_dnsmasq_configure == 1) { status = dnsmasq_configure(true); diff --git a/tachyon/files/usr/lib/service/parental_quota.uc b/tachyon/files/usr/lib/service/parental_quota.uc index 986373dc..deb2cf18 100644 --- a/tachyon/files/usr/lib/service/parental_quota.uc +++ b/tachyon/files/usr/lib/service/parental_quota.uc @@ -79,6 +79,13 @@ function list_option(section, key) { return result; } +function str_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + if (value == null || value == "") + return fallback; + return as_string(value); +} + function quota_schedules() { let result = []; let profiles_by_name = {}; @@ -112,6 +119,10 @@ function quota_schedules() { let minutes = int_option(section, "daily_quota_minutes", 0); if (minutes <= 0) continue; + let target = str_option(section, "target", ""); + let has_blocked_domains = length(list_option(section, "blocked_domains")) > 0 || str_option(section, "blocked_domains", "") != ""; + if (target != "all" && (target == "domains" || target == "sections" || has_blocked_domains)) + continue; let devices = []; let idents_seen = {}; for (let dev in list_option(section, "device_ip")) { @@ -150,13 +161,6 @@ function quota_schedules() { return result; } -function str_option(section, key, fallback) { - let value = object_or_empty(section)[key]; - if (value == null || value == "") - return fallback; - return as_string(value); -} - function guest_mode_config() { let sections = uci_core.section_objects(CONFIG_NAME, "guest_mode"); if (length(sections) == 0) @@ -450,7 +454,27 @@ function tick() { let gm_cfg = guest_mode_config(); if (length(schedules) == 0 && gm_cfg == null) { - // No quota schedules configured and no guest quotas active + // No quota schedules configured and no guest quotas active: + // ensure any previously blocked devices are unblocked in nft sets and state! + let state = read_state(); + let had_blocked = false; + for (let ident, entry in state.devices) { + if (entry && entry.blocked) { + entry.blocked = false; + had_blocked = true; + } + } + for (let ident, entry in state.guest_devices) { + if (entry && entry.blocked) { + entry.blocked = false; + had_blocked = true; + } + } + if (had_blocked) { + write_state(state); + sync_enforcement([], []); + log_message("all quota rules removed or disabled; unblocked all devices", "info"); + } return 0; } @@ -469,19 +493,52 @@ function tick() { let minutes = int(entry.minutes || 0); let was_blocked = entry.blocked == true; - if (!was_blocked && device_active(ident)) { + if (was_blocked) { + if (minutes < schedule.minutes) { + // Quota was increased by administrator: unblock device! + let is_active = device_active(ident); + if (is_active) + minutes++; + let is_blocked = minutes >= schedule.minutes; + devices[ident] = { minutes, blocked: is_blocked }; + if (!is_blocked) { + log_message("device " + ident + " unblocked after quota increase (used " + as_string(minutes) + "/" + as_string(schedule.minutes) + " min)", "info"); + send_notification("✅ *Parental control*: квота устройства `" + ident + "` (" + schedule.label + ") увеличена до " + as_string(schedule.minutes) + " мин, доступ разблокирован."); + } + } else { + devices[ident] = { minutes, blocked: true }; + } + } else if (device_active(ident)) { minutes++; - devices[ident] = { minutes, blocked: minutes >= schedule.minutes }; - if (minutes >= schedule.minutes) { + let is_blocked = minutes >= schedule.minutes; + devices[ident] = { minutes, blocked: is_blocked }; + if (is_blocked) { log_message("device " + ident + " hit daily quota (" + as_string(schedule.minutes) + " min); blocking until midnight", "info"); send_notification("⏳ *Parental control*: устройство `" + ident + "` (" + schedule.label + ") исчерпало дневную квоту (" + as_string(schedule.minutes) + " мин), доступ заблокирован до полуночи."); } } else { - devices[ident] = { minutes, blocked: was_blocked }; + devices[ident] = { minutes, blocked: false }; } } } + // Devices in state that are no longer part of any active quota schedule must be unblocked + let active_idents = {}; + for (let schedule in schedules) { + for (let device in schedule.devices) { + active_idents[device.ident] = true; + } + } + for (let ident, entry in devices) { + if (!active_idents[ident]) { + entry = object_or_empty(entry); + if (entry.blocked == true) { + log_message("device " + ident + " unblocked (no longer in active quota schedule)", "info"); + } + devices[ident] = { minutes: int(entry.minutes || 0), blocked: false }; + } + } + // 2. Guest Mode quotas if (gm_cfg != null) { let target_guests = []; @@ -517,7 +574,17 @@ function tick() { let bytes = int(traffic_bytes_map[ident] || entry.bytes || 0); let was_blocked = entry.blocked == true; - if (!was_blocked) { + if (was_blocked) { + let time_exceeded = gm_cfg.time_limit > 0 && minutes >= gm_cfg.time_limit; + let traffic_mb = int(bytes / (1024 * 1024)); + let traffic_exceeded = gm_cfg.traffic_limit > 0 && traffic_mb >= gm_cfg.traffic_limit; + if (!time_exceeded && !traffic_exceeded) { + guest_devices[ident] = { minutes, bytes, blocked: false }; + log_message("guest device " + ident + " unblocked (limit increased)", "info"); + } else { + guest_devices[ident] = { minutes, bytes, blocked: true, reason: entry.reason }; + } + } else { if (guest.active) minutes++; @@ -534,10 +601,13 @@ function tick() { } else { guest_devices[ident] = { minutes, bytes, blocked: false }; } - } else { - guest_devices[ident] = { minutes, bytes, blocked: true, reason: entry.reason }; } } + } else { + for (let ident, entry in guest_devices) { + entry = object_or_empty(entry); + guest_devices[ident] = { minutes: int(entry.minutes || 0), bytes: int(entry.bytes || 0), blocked: false }; + } } state.devices = devices; diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index b0aefe0c..c2870eb1 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -945,26 +945,8 @@ function schedule_blocked_domains(schedule) { function enabled_content_block_schedules() { let result = []; - let profiles_by_name = {}; - ctx.uci_cursor().foreach(CONFIG_NAME, "profile", function(profile) { - if (section_enabled(profile)) - profiles_by_name[profile[".name"]] = profile; - }); ctx.uci_cursor().foreach(CONFIG_NAME, "schedule", function(schedule) { - let quota_minutes = int(option(schedule, "daily_quota_minutes", 0)); - if (quota_minutes <= 0) { - for (let prof_ref in list_option(schedule, "profile")) { - let p = profiles_by_name[prof_ref]; - if (p) { - let pq = int(option(p, "daily_quota_minutes", 0)); - if (pq > 0) { quota_minutes = pq; break; } - } - } - } - let has_interval = trim(option(schedule, "start_time", "")) != "" || trim(option(schedule, "end_time", "")) != ""; if (section_enabled(schedule) && length(schedule_blocked_domains(schedule)) > 0) { - if (!has_interval && quota_minutes > 0) - return; push(result, schedule); } }); diff --git a/tests/content_blocking.sh b/tests/content_blocking.sh index ddadb080..bb09a9d8 100644 --- a/tests/content_blocking.sh +++ b/tests/content_blocking.sh @@ -373,5 +373,47 @@ assert_contains "$OUT_PROF" '192.168.1.180/32' "profile: SafeSearch scoped to pr # Profile blocked domains must be present in DNS rules assert_contains "$OUT_PROF" 'roblox.com' "profile: roblox.com blocked" -printf 'Content blocking and family profile checks passed\n' +# ─── 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' diff --git a/tests/parental_control.sh b/tests/parental_control.sh index 252ae110..a1d5349a 100644 --- a/tests/parental_control.sh +++ b/tests/parental_control.sh @@ -353,5 +353,136 @@ if grep -Eq "parental_(forward|control).*192\.168\.1\.222.*counter.*drop" "$NFT_ fail "24/7 Schedule with profile daily_quota_minutes must NOT add static counter drop" fi -printf 'Parental control, profiles and schedule tests passed\n' +# ─── 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'