Files
NeoTachyon/tachyon/files/usr/lib/service/parental_quota.uc
T

376 lines
13 KiB
Ucode

#!/usr/bin/env ucode
// Parental daily time quotas: schedules with `daily_quota_minutes` limit how
// long a listed device may use the network per day. A cron tick (every
// minute) counts active devices, blocks exhausted ones via the nftables set
// tachyon_quota_block, and resets everything after midnight.
//
// NOTE: ucode does not hoist function declarations - keep callees above
// their first caller.
let fs = require("fs");
let common = require("core.common");
let uci_core = require("core.uci");
let as_string = common.as_string;
let object_or_empty = common.object_or_empty;
let array_or_empty = common.array_or_empty;
let write_json = common.write_json;
let shell_quote = common.shell_quote;
let command_from_args = common.command_from_args;
let command_status = common.command_status;
let command_success_from_args = common.command_success_from_args;
let command_output_from_args = common.command_output_from_args;
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "TachyonTable";
const STATE_FILE = getenv("PARENTAL_QUOTA_STATE_FILE") || "/var/run/tachyon/parental_quotas.json";
const CRON_MARKER = getenv("PARENTAL_QUOTA_CRON_MARKER") || "# tachyon-parental-quota";
const TACHYON_BIN = getenv("TACHYON_BIN") || "/usr/bin/tachyon";
const SET_ETHER = "tachyon_quota_block";
const SET_IP = "tachyon_quota_block_ip";
const MAC_RE = /^([0-9a-fA-F]{2}[:-]){5}[0-9A-Fa-f]{2}$/;
function log_message(message, level) {
level = as_string(level || "info");
command_success_from_args([ "logger", "-t", "tachyon", "[parental-quota] [" + level + "] " + as_string(message) ]);
}
function today_str() {
return trim(command_output_from_args([ "date", "+%Y-%m-%d" ]));
}
function int_option(section, key, fallback) {
let value = object_or_empty(section)[key];
if (value == null || value == "")
return int(fallback);
let parsed = int(value);
return parsed > 0 ? parsed : 0;
}
function bool_option(section, key, fallback) {
let value = object_or_empty(section)[key];
if (value == null)
return !!fallback;
let s = lc(as_string(value));
return s == "1" || s == "true" || s == "yes" || s == "on";
}
function list_option(section, key) {
let value = object_or_empty(section)[key];
if (value == null)
return [];
if (type(value) != "array")
value = [ value ];
let result = [];
for (let item in value) {
item = trim(as_string(item));
if (item != "")
push(result, item);
}
return result;
}
function quota_schedules() {
let result = [];
let profiles_by_name = {};
for (let p in uci_core.section_objects(CONFIG_NAME, "profile")) {
let p_name = as_string(object_or_empty(p)[".name"]);
profiles_by_name[p_name] = p;
if (!bool_option(p, "enabled", true))
continue;
let p_minutes = int_option(p, "daily_quota_minutes", 0);
if (p_minutes <= 0)
continue;
let p_devices = [];
for (let dev in list_option(p, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null;
if (!is_mac && match(dev, /:/) != null)
continue;
push(p_devices, { ident: lc(dev), is_mac });
}
if (length(p_devices) == 0)
continue;
push(result, {
label: as_string(object_or_empty(p).label || p_name),
minutes: p_minutes,
devices: p_devices
});
}
for (let section in uci_core.section_objects(CONFIG_NAME, "schedule")) {
if (!bool_option(section, "enabled", true))
continue;
let minutes = int_option(section, "daily_quota_minutes", 0);
if (minutes <= 0)
continue;
let devices = [];
let idents_seen = {};
for (let dev in list_option(section, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null;
if (!is_mac && match(dev, /:/) != null)
continue;
let ident = lc(dev);
if (!idents_seen[ident]) {
idents_seen[ident] = true;
push(devices, { ident, is_mac });
}
}
for (let prof_ref in list_option(section, "profile")) {
let p = profiles_by_name[prof_ref];
if (p && bool_option(p, "enabled", true)) {
for (let dev in list_option(p, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null;
if (!is_mac && match(dev, /:/) != null)
continue;
let ident = lc(dev);
if (!idents_seen[ident]) {
idents_seen[ident] = true;
push(devices, { ident, is_mac });
}
}
}
}
if (length(devices) == 0)
continue;
push(result, {
label: as_string(object_or_empty(section).label || object_or_empty(section)[".name"]),
minutes,
devices
});
}
return result;
}
function read_state() {
let raw = fs.readfile(STATE_FILE);
if (raw == null || trim(as_string(raw)) == "")
return { day: "", devices: {} };
let parsed = object_or_empty(json(raw));
return {
day: as_string(parsed["day"] || ""),
devices: object_or_empty(parsed["devices"])
};
}
function write_state(state) {
let tmp = STATE_FILE + ".tmp." + int(time());
if (!fs.writefile(tmp, sprintf("%J", state))) {
log_message("failed to write state file", "warn");
return;
}
command_status(command_from_args([ "mv", "-f", tmp, STATE_FILE ]));
}
function device_active(ident) {
for (let line in split(command_output_from_args([ "ip", "neigh", "show" ]), "\n")) {
line = as_string(line);
if (index(lc(line), ident) < 0)
continue;
if (match(line, /(REACHABLE|STALE|DELAY|PROBE|PERMANENT)/) != null)
return true;
}
return false;
}
function send_notification(message) {
let tcfg = object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
if (as_string(tcfg.enabled || "0") != "1" || !tcfg.bot_token || !tcfg.admin_ids)
return;
system(common.background_command(TACHYON_BIN + " telegram send " + shell_quote(message)));
}
function nft_chain_has_quota(chain) {
return index(command_output_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, chain ]) || "", "tachyon-quota") >= 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 (command_status(command_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ]) + " >/dev/null 2>&1") != 0)
return false;
if (command_status(command_from_args([ "nft", "list", "set", "inet", NFT_TABLE_NAME, SET_ETHER ]) + " >/dev/null 2>&1") != 0) {
command_status("nft -f - <<'EOF'\n" +
"table inet " + NFT_TABLE_NAME + " {\n" +
" set " + SET_ETHER + " { type ether_addr; flags interval; }\n" +
"}\nEOF\n");
}
if (command_status(command_from_args([ "nft", "list", "set", "inet", NFT_TABLE_NAME, SET_IP ]) + " >/dev/null 2>&1") != 0) {
command_status("nft -f - <<'EOF'\n" +
"table inet " + NFT_TABLE_NAME + " {\n" +
" set " + SET_IP + " { type ipv4_addr; flags interval; }\n" +
"}\nEOF\n");
}
if (!nft_chain_has_quota("parental_control"))
command_status("nft insert rule inet " + NFT_TABLE_NAME + " parental_control ether saddr @" + SET_ETHER + " counter drop comment \"tachyon-quota\"");
if (!nft_chain_has_quota("parental_control"))
command_status("nft insert rule inet " + NFT_TABLE_NAME + " parental_control ip saddr @" + SET_IP + " counter drop comment \"tachyon-quota\"");
if (!nft_chain_has_quota("parental_forward"))
command_status("nft insert rule inet " + NFT_TABLE_NAME + " parental_forward ether saddr @" + SET_ETHER + " counter drop comment \"tachyon-quota\"");
if (!nft_chain_has_quota("parental_forward"))
command_status("nft insert rule inet " + NFT_TABLE_NAME + " parental_forward ip saddr @" + SET_IP + " counter drop comment \"tachyon-quota\"");
return true;
}
function sync_block_set(set_name, idents) {
command_status(command_from_args([ "nft", "flush", "set", "inet", NFT_TABLE_NAME, set_name ]) + " >/dev/null 2>&1");
if (length(idents) > 0)
command_status(command_from_args([ "nft", "add", "element", "inet", NFT_TABLE_NAME, set_name, "{ " + join(", ", idents) + " }" ]) + " >/dev/null 2>&1");
}
function sync_enforcement(blocked_macs, blocked_ips) {
if (!ensure_nft_rules())
return;
sync_block_set(SET_ETHER, blocked_macs);
sync_block_set(SET_IP, blocked_ips);
}
function crontab_lines() {
return split(command_output_from_args([ "crontab", "-l" ]), "\n");
}
function cron_line() {
return "* * * * * " + TACHYON_BIN + " parental_quota_tick " + CRON_MARKER;
}
function write_crontab(lines) {
let text = "";
for (let i = 0; i < length(lines); i++) {
if (as_string(lines[i]) == "" && i == length(lines) - 1)
continue;
text += as_string(lines[i]) + "\n";
}
let tmp = "/tmp/tachyon-parental-cron." + as_string(int(time()));
if (!fs.writefile(tmp, text))
return false;
let ok = command_status(command_from_args([ "crontab", tmp ])) == 0;
fs.unlink(tmp);
return ok;
}
function remove_cron() {
let line = cron_line();
let changed = false;
let lines = [];
for (let existing in crontab_lines()) {
if (trim(as_string(existing)) == line) {
changed = true;
continue;
}
push(lines, existing);
}
if (!changed)
return 0;
return write_crontab(lines) ? 0 : 1;
}
function install_cron() {
// Only install the cron tick if there is at least one schedule with a
// daily_quota_minutes limit. Without quotas the job produces nft stderr
// which crond logs as cron.err every minute — pure noise.
if (length(quota_schedules()) == 0)
return remove_cron();
let line = cron_line();
let lines = crontab_lines();
for (let existing in lines)
if (trim(as_string(existing)) == line)
return 0;
push(lines, line);
log_message("cron job installed", "debug");
return write_crontab(lines) ? 0 : 1;
}
function blocked_lists(state) {
// MAC/IP idents currently over quota. On day rollover nothing stays
// blocked because counters start fresh.
let macs = [];
let ips = [];
for (let ident, entry in state.devices) {
entry = object_or_empty(entry);
if (entry.blocked != true)
continue;
if (match(ident, MAC_RE) != null)
push(macs, ident);
else
push(ips, ident);
}
return { macs, ips };
}
function tick() {
let schedules = quota_schedules();
if (length(schedules) == 0) {
// No quota schedules configured — skip all nft operations so crond
// does not produce cron.err noise from chains that don't exist.
return 0;
}
let today = today_str();
let state = read_state();
if (as_string(state.day) != today)
state = { day: today, devices: {} };
let devices = object_or_empty(state.devices);
for (let schedule in schedules) {
for (let device in schedule.devices) {
let ident = device.ident;
let entry = object_or_empty(devices[ident]);
let minutes = int(entry.minutes || 0);
let was_blocked = entry.blocked == true;
if (!was_blocked && device_active(ident)) {
minutes++;
devices[ident] = { minutes, blocked: minutes >= schedule.minutes };
if (minutes >= schedule.minutes) {
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 };
}
}
}
state.devices = devices;
write_state(state);
let lists = blocked_lists(state);
sync_enforcement(lists.macs, lists.ips);
return 0;
}
function status_json() {
let schedules = quota_schedules();
let state = read_state();
let tracked = {};
for (let ident, entry in object_or_empty(state.devices)) {
entry = object_or_empty(entry);
tracked[ident] = { minutes: int(entry.minutes || 0), blocked: entry.blocked == true };
}
write_json({
configured: length(schedules) > 0,
schedules: length(schedules),
day: as_string(state.day || ""),
devices: tracked
});
}
let mode = ARGV[0] || "";
if (mode == "tick")
exit(tick());
else if (mode == "status")
status_json();
else if (mode == "install-cron")
exit(install_cron());
else if (mode == "remove-cron")
exit(remove_cron());
else {
warn("Usage: service/parental_quota.uc <tick|status|install-cron|remove-cron>\n");
exit(1);
}