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:
Dushnilin
2026-09-12 21:31:03 +03:00
parent dceb04677e
commit 040b1291c6
7 changed files with 265 additions and 39 deletions
-3
View File
@@ -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);
+85 -15
View File
@@ -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);
}
});