fix(parental): resolve quota increase unblocking and preserve domain rules
- Unblock devices when admin increases daily_quota_minutes in parental_quota.uc - Unblock devices removed from active quota schedules and flush sets on disable - Ignore target != 'all' schedules in parental_quota.uc screen-time dropper - Do not suppress domain DNS reject or nft redirect rules when quota is set - Run parental_quota.uc tick on service start and reload for instant sync - Add target:all dependency to daily_quota_minutes in LuCI parental view - Add tests in content_blocking.sh and parental_control.sh
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
+132
-1
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user