From fadfbc2511c05b509da1d15bf7472e8b64c2542b Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Sat, 12 Sep 2026 18:52:46 +0300 Subject: [PATCH] fix(parental): resolve daily quota from profile in nft/singbox rules and harden cron tick invocation --- tachyon/files/usr/bin/tachyon | 10 ++++-- tachyon/files/usr/lib/nft/apply.uc | 23 ++++++++++++-- .../files/usr/lib/service/parental_quota.uc | 23 ++++++++++++-- tachyon/files/usr/lib/singbox/generator.uc | 14 +++++++++ tests/parental_control.sh | 31 +++++++++++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/tachyon/files/usr/bin/tachyon b/tachyon/files/usr/bin/tachyon index be0ebed7..3aa12ea2 100755 --- a/tachyon/files/usr/bin/tachyon +++ b/tachyon/files/usr/bin/tachyon @@ -363,12 +363,16 @@ function run_agent(user_args) { } function main() { - let command = ARGV[0] || ""; + let start_idx = 0; + while (start_idx < length(ARGV) && trim(as_string(ARGV[start_idx])) == "") + start_idx++; + + let command = ARGV[start_idx] || ""; // agent command gets all remaining args passed verbatim if (command == "agent") { let user_args = []; - for (let i = 1; i < length(ARGV); i++) + for (let i = start_idx + 1; i < length(ARGV); i++) push(user_args, ARGV[i]); return run_agent(user_args); } @@ -380,7 +384,7 @@ function main() { } let user_args = []; - for (let i = 1; i < length(ARGV); i++) + for (let i = start_idx + 1; i < length(ARGV); i++) push(user_args, ARGV[i]); return run_spec(command, spec, user_args); diff --git a/tachyon/files/usr/lib/nft/apply.uc b/tachyon/files/usr/lib/nft/apply.uc index 5cbc5dc2..2c81d433 100644 --- a/tachyon/files/usr/lib/nft/apply.uc +++ b/tachyon/files/usr/lib/nft/apply.uc @@ -959,6 +959,25 @@ function resolve_schedule_devices(schedule, profiles) { return result; } +function resolve_schedule_quota_minutes(schedule, profiles) { + let q = int(option(schedule, "daily_quota_minutes", 0)); + if (q > 0) return q; + let prof_names = list_option(schedule, "profile"); + if (length(prof_names) == 0) { + let single_p = option(schedule, "profile", ""); + if (single_p != "") prof_names = [ single_p ]; + } + for (let p_name in prof_names) { + for (let p in (profiles || [])) { + if (as_string(object_or_empty(p)[".name"]) == p_name && bool_option(p, "enabled", true)) { + let pq = int(option(p, "daily_quota_minutes", 0)); + if (pq > 0) return pq; + } + } + } + return 0; +} + function nft_add_profile_doh_block_rules(profiles, table) { if (!profiles || length(profiles) == 0) return true; @@ -1055,7 +1074,7 @@ function nft_add_schedule_rules_from_schedules(schedules, sections, table, profi [ "meta", "hour", sprintf("\"%s\"-\"%s\"", interval[0], interval[1]) ] : null; let base_match = nft_schedule_rule_base_match(dev_str, is_mac, family, days_args, time_args); - let quota_minutes = int(option(schedule, "daily_quota_minutes", 0)); + let quota_minutes = resolve_schedule_quota_minutes(schedule, profiles); let has_sched_domains = length(list_option(schedule, "blocked_domains")) > 0 || option(schedule, "blocked_domains", "") != ""; if ((target == "all" || (length(target_sec_names) == 0 && target != "sections" && target != "domains")) && !has_sched_domains && target != "domains") { if (always_on && quota_minutes > 0) @@ -1163,7 +1182,7 @@ 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 = int(option(schedule, "daily_quota_minutes", 0)); + let quota_minutes = resolve_schedule_quota_minutes(schedule, profiles); if (always_on && quota_minutes > 0) continue; diff --git a/tachyon/files/usr/lib/service/parental_quota.uc b/tachyon/files/usr/lib/service/parental_quota.uc index af8fe419..b07e23bf 100644 --- a/tachyon/files/usr/lib/service/parental_quota.uc +++ b/tachyon/files/usr/lib/service/parental_quota.uc @@ -239,6 +239,8 @@ function write_state(state) { } function device_active(ident) { + if (!ident || trim(as_string(ident)) == "") + return false; for (let line in split(command_output_from_args([ "ip", "neigh", "show" ]), "\n")) { line = as_string(line); if (index(lc(line), ident) < 0) @@ -289,12 +291,20 @@ function nft_insert_quota_rule(chain, match_type) { return rc == 0; } +function nft_chain_exists(chain) { + let rc = command_status(command_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, chain ]) + " >/dev/null 2>&1"); + return rc == 0; +} + function ensure_nft_rules() { // The whole table disappears on every Tachyon nft rebuild; recreate our // pieces on demand so ticks self-heal without touching apply.uc ordering. if (!nft_table_exists()) return false; + if (!nft_chain_exists("parental_control") || !nft_chain_exists("parental_forward")) + return false; + // Create quota sets if missing if (!nft_set_exists(SET_ETHER)) nft_create_set(SET_ETHER, "ether_addr"); @@ -343,10 +353,13 @@ function cron_line() { function write_crontab(lines) { let text = ""; for (let i = 0; i < length(lines); i++) { - if (as_string(lines[i]) == "" && i == length(lines) - 1) + let l = trim(as_string(lines[i])); + if (l == "") continue; - text += as_string(lines[i]) + "\n"; + text += l + "\n"; } + if (text == "") + text = "\n"; let tmp = "/tmp/tachyon-parental-cron." + as_string(int(time())); if (!fs.writefile(tmp, text)) return false; @@ -559,7 +572,11 @@ function reset_quotas() { return 0; } -let mode = ARGV[0] || ""; +let mode = ""; +for (let a in ARGV) { + let t = trim(as_string(a)); + if (t != "") { mode = t; break; } +} if (mode == "tick") exit(tick()); diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index a049e19f..0be00122 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -945,8 +945,22 @@ 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) diff --git a/tests/parental_control.sh b/tests/parental_control.sh index f0af026a..252ae110 100644 --- a/tests/parental_control.sh +++ b/tests/parental_control.sh @@ -322,5 +322,36 @@ if grep -Eq "parental_(forward|control).*192\.168\.1\.181.*counter.*drop" "$NFT_ 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 + printf 'Parental control, profiles and schedule tests passed\n'