feat: родительский контроль по расписанию, мультивыбор секций, перехват DNS и обновление локализации (1.2.86)

This commit is contained in:
Dushnilin
2026-08-17 20:34:42 +03:00
parent c35380200f
commit 1f116e571d
15 changed files with 7129 additions and 609 deletions
@@ -29,7 +29,7 @@ Returns lightweight status of all core daemons.
```json
{
"status": "healthy",
"version": "1.2.85",
"version": "1.2.86",
"uptime_seconds": 18450,
"singbox_running": true,
"watchdog_running": true,
+2 -2
View File
@@ -20,12 +20,12 @@ function stripIllegalReturn(code) {
const files = await glob([
'src/**/*.ts',
'src/**/*.tsx',
'../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js',
'../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js',
'../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/**/*.js',
], {
cwd: __dirname,
ignore: [
'**/*.test.ts',
'**/main.js',
],
absolute: true,
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -43,22 +43,25 @@ function normalizeOptionValues(value) {
}
function normalizeLocalDeviceName(name) {
return `${name || ""}`.trim().replace(/\.lan$/i, "");
const clean = `${name || ""}`.trim().replace(/\.lan$/i, "");
return clean === "*" ? "" : clean;
}
function addLocalDeviceChoice(choices, ip, name) {
function addLocalDeviceChoice(choices, ip, name, mac) {
const normalizedIp = `${ip || ""}`.trim();
const normalizedName = normalizeLocalDeviceName(name);
if (!normalizedIp || !normalizedName) {
if (!normalizedIp || !main.validateIP(normalizedIp).valid) {
return;
}
if (!main.validateIP(normalizedIp).valid) {
return;
if (normalizedName) {
choices[normalizedIp] = `${normalizedName} (${normalizedIp})`;
} else if (mac) {
choices[normalizedIp] = `${normalizedIp} [${mac.toLowerCase()}]`;
} else if (!choices[normalizedIp]) {
choices[normalizedIp] = `${normalizedIp}`;
}
choices[normalizedIp] = normalizedName;
}
function addRouterIp(routerIps, ip) {
@@ -110,7 +113,7 @@ function buildLocalDeviceChoices(hostHints, dhcpLeases, networkInterfaces, dhcpH
const routerIps = buildRouterIpMap(networkInterfaces);
if (hostHints && typeof hostHints === "object") {
Object.values(hostHints).forEach((hint) => {
Object.entries(hostHints).forEach(([mac, hint]) => {
if (!hint || typeof hint !== "object") {
return;
}
@@ -120,7 +123,7 @@ function buildLocalDeviceChoices(hostHints, dhcpLeases, networkInterfaces, dhcpH
...normalizeOptionValues(hint.ipv4),
...normalizeOptionValues(hint.ipv6),
].forEach((ip) => {
addLocalDeviceChoice(choices, ip, hint.name);
addLocalDeviceChoice(choices, ip, hint.name, mac);
});
});
}
@@ -131,14 +134,14 @@ function buildLocalDeviceChoices(hostHints, dhcpLeases, networkInterfaces, dhcpH
return;
}
addLocalDeviceChoice(choices, lease.ipaddr, lease.hostname);
addLocalDeviceChoice(choices, lease.ipaddr, lease.hostname, lease.mac);
});
}
if (Array.isArray(dhcpHosts)) {
dhcpHosts.forEach((host) => {
if (host && host.ip && main.validateIP(host.ip).valid) {
addLocalDeviceChoice(choices, host.ip, host.name || host.mac);
addLocalDeviceChoice(choices, host.ip, host.name, host.mac);
}
});
}
@@ -0,0 +1,316 @@
"use strict";
"require baseclass";
"require form";
"require ui";
"require uci";
"require view.tachyon.main as main";
"require view.tachyon.local_devices as local_devices";
const UCI_PACKAGE = "tachyon";
const DAY_LABELS = {
monday: _("Monday"),
tuesday: _("Tuesday"),
wednesday: _("Wednesday"),
thursday: _("Thursday"),
friday: _("Friday"),
saturday: _("Saturday"),
sunday: _("Sunday"),
};
const DAY_SHORT_LABELS = {
monday: _("Mon"),
tuesday: _("Tue"),
wednesday: _("Wed"),
thursday: _("Thu"),
friday: _("Fri"),
saturday: _("Sat"),
sunday: _("Sun"),
};
const DAY_INDEX_MAP = {
0: "sunday",
1: "monday",
2: "tuesday",
3: "wednesday",
4: "thursday",
5: "friday",
6: "saturday",
};
function validateTime(sectionId, value) {
if (!value) {
return _("Time is required (e.g. 22:00)");
}
const str = `${value}`.trim();
if (!/^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(str)) {
return _("Invalid time format. Expected HH:MM (e.g. 22:00)");
}
return true;
}
function normalizeDays(raw) {
if (!raw) return [];
if (Array.isArray(raw)) return raw.map((d) => `${d}`.toLowerCase().trim()).filter(Boolean);
return `${raw}`.split(/\s+/).map((d) => d.toLowerCase().trim()).filter(Boolean);
}
function normalizeListValues(raw) {
if (!raw) return [];
if (Array.isArray(raw)) return raw.map((item) => `${item}`.trim()).filter(Boolean);
return `${raw}`.split(/\s+/).map((item) => item.trim()).filter(Boolean);
}
function isScheduleCurrentlyActive(sectionId) {
const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0";
if (!enabled) return false;
const startTime = uci.get(UCI_PACKAGE, sectionId, "start_time") || "00:00";
const endTime = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59";
const rawDays = uci.get(UCI_PACKAGE, sectionId, "days");
const days = normalizeDays(rawDays);
const now = new Date();
const currentDay = DAY_INDEX_MAP[now.getDay()];
if (days.length > 0 && days.length < 7 && !days.includes(currentDay)) {
return false;
}
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const [sH, sM] = startTime.split(":").map(Number);
const [eH, eM] = endTime.split(":").map(Number);
const startMinutes = (sH || 0) * 60 + (sM || 0);
const endMinutes = (eH || 0) * 60 + (eM || 0);
if (startMinutes > endMinutes) {
// Crosses midnight, e.g. 22:00 -> 08:00
return currentMinutes >= startMinutes || currentMinutes <= endMinutes;
}
return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
}
function formatDaysDisplay(rawDays) {
const days = normalizeDays(rawDays);
if (!days || days.length === 0 || days.length === 7) {
return _("Every day");
}
const weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"];
const weekends = ["saturday", "sunday"];
const isWeekdays =
days.length === 5 && weekdays.every((d) => days.includes(d));
if (isWeekdays) return _("Weekdays (Mon-Fri)");
const isWeekends =
days.length === 2 && weekends.every((d) => days.includes(d));
if (isWeekends) return _("Weekends (Sat-Sun)");
return days.map((d) => DAY_SHORT_LABELS[d] || d).join(", ");
}
function createParentalContent(section) {
// Enabled switch
let o = section.option(form.Flag, "enabled", _("Enable"));
o.default = "1";
o.rmempty = false;
o.editable = true;
o.width = "5rem";
// Label / Rule name (Grid column + modal)
o = section.option(form.Value, "label", _("Rule Name"));
o.modalonly = false;
o.rmempty = false;
o.validate = function (sectionId, value) {
return value && `${value}`.trim().length > 0 ? true : _("Rule name is required");
};
o.placeholder = _("e.g. Kids Phone (Night YouTube)");
// Device column in table
o = section.option(form.DummyValue, "_device_display", _("Device / IP"));
o.modalonly = false;
o.rawhtml = true;
o.cfgvalue = function (sectionId) {
const rawIp = uci.get(UCI_PACKAGE, sectionId, "device_ip");
const ips = Array.isArray(rawIp) ? rawIp : [rawIp].filter(Boolean);
if (ips.length === 0) return `<span style="opacity:0.5;">${_("All devices")}</span>`;
return ips.map((ip) => `<span class="badge" style="background:#2a3441;color:#63b3ed;padding:3px 8px;border-radius:6px;font-size:11px;font-family:monospace;margin-right:4px;font-weight:600;">${ip}</span>`).join(" ");
};
// Target column in table
o = section.option(form.DummyValue, "_target_display", _("Target"));
o.modalonly = false;
o.rawhtml = true;
o.cfgvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
if (target === "all") {
return `<span style="color:#fc8181;font-weight:600;">🚫 ${_("All Internet")}</span>`;
}
const rawSecs = uci.get(UCI_PACKAGE, sectionId, "sections");
const secNames = normalizeListValues(rawSecs);
if (secNames.length === 0 && target !== "sections") {
secNames.push(target);
}
if (secNames.length === 0) {
return `<span style="opacity:0.6;">${_("No sections selected")}</span>`;
}
return secNames.map((name) => {
const sec = uci.get(UCI_PACKAGE, name);
const label = (sec && (sec.label || sec[".name"])) || name;
return `<span class="badge" style="background:#2d3748;color:#f6ad55;padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid rgba(246,173,85,0.3);">🔒 ${label}</span>`;
}).join(" ");
};
// Action column in table
o = section.option(form.DummyValue, "_action_display", _("Mode"));
o.modalonly = false;
o.rawhtml = true;
o.cfgvalue = function (sectionId) {
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
if (action === "allow") {
return `<span style="color:#68d391;font-weight:500;">✅ ${_("Allow in interval")}</span>`;
}
return `<span style="color:#fc8181;font-weight:500;">🚫 ${_("Block in interval")}</span>`;
};
// Schedule column in table
o = section.option(form.DummyValue, "_schedule_display", _("Schedule"));
o.modalonly = false;
o.rawhtml = true;
o.cfgvalue = function (sectionId) {
const s = uci.get(UCI_PACKAGE, sectionId, "start_time") || "00:00";
const e = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59";
const days = formatDaysDisplay(uci.get(UCI_PACKAGE, sectionId, "days"));
return `<div style="line-height:1.3;"><strong style="color:var(--text-color, #fff);">${s} — ${e}</strong><br><small style="opacity:0.7;">${days}</small></div>`;
};
// Live status badge in table
o = section.option(form.DummyValue, "_live_status", _("Status"));
o.modalonly = false;
o.rawhtml = true;
o.cfgvalue = function (sectionId) {
const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0";
if (!enabled) {
return `<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:#4a5568;color:#e2e8f0;">⏸ ${_("Disabled")}</span>`;
}
const active = isScheduleCurrentlyActive(sectionId);
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
if (active) {
const color = action === "allow" ? "#48bb78" : "#e53e3e";
const text = action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)");
return `<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:rgba(${action === "allow" ? "72,187,120" : "229,62,62"},0.2);color:${color};border:1px solid rgba(${action === "allow" ? "72,187,120" : "229,62,62"},0.4);"><span style="width:6px;height:6px;border-radius:50%;background:${color};margin-right:5px;box-shadow:0 0 6px ${color};"></span>${text}</span>`;
}
return `<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:rgba(74,85,104,0.3);color:#a0aec0;border:1px solid rgba(74,85,104,0.3);"><span style="width:6px;height:6px;border-radius:50%;background:#718096;margin-right:5px;"></span>${_("Pending")}</span>`;
};
// ─── Modal only configuration options ──────────────────────────────────────
// Device selector with LAN hostnames integration
o = section.option(
form.DynamicList,
"device_ip",
_("Target Devices (IP / Hostname)"),
_("Select LAN devices to apply this schedule to, or enter IP addresses manually."),
);
o.modalonly = true;
o.rmempty = false;
o.placeholder = "192.168.1.150";
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
return local_devices.createLocalDeviceDynamicListWidget(
this,
sectionId,
cfgvalue,
);
};
// Target Type selector
o = section.option(
form.ListValue,
"target",
_("Target Scope"),
_("Choose whether to restrict the entire internet or only specific Tachyon routing sections."),
);
o.modalonly = true;
o.default = "all";
o.value("all", _("All Internet (Complete internet cutoff)"));
o.value("sections", _("Specific Sections (Select sections below)"));
// Multi-select for sections
o = section.option(
form.MultiValue,
"sections",
_("Select Sections"),
_("Choose which routing sections to apply this schedule to."),
);
o.modalonly = true;
o.depends("target", "sections");
o.load = function (sectionId) {
this.keylist = [];
this.vallist = [];
const secList = uci.sections(UCI_PACKAGE, "section") || [];
secList.forEach((s) => {
const sName = s[".name"];
const sLabel = s.label ? `${s.label} (${s.action || "rule"})` : sName;
this.value(sName, sLabel);
});
return form.MultiValue.prototype.load.apply(this, [sectionId]);
};
// Action Mode (Block vs Allow)
o = section.option(
form.ListValue,
"action",
_("Action Mode"),
_("Select whether to disable the target during the schedule or enable it ONLY during the schedule."),
);
o.modalonly = true;
o.default = "block";
o.value("block", _("Block / Disable in scheduled hours (Standard)"));
o.value("allow", _("Allow / Enable ONLY in scheduled hours (White period)"));
// Start Time
o = section.option(
form.Value,
"start_time",
_("Start Time (HH:MM)"),
_("Beginning of the schedule interval (e.g. 22:00)"),
);
o.modalonly = true;
o.default = "22:00";
o.placeholder = "22:00";
o.validate = validateTime;
// End Time
o = section.option(
form.Value,
"end_time",
_("End Time (HH:MM)"),
_("End of the schedule interval (e.g. 08:00). Supports spans crossing midnight."),
);
o.modalonly = true;
o.default = "08:00";
o.placeholder = "08:00";
o.validate = validateTime;
// Days of week
o = section.option(
form.MultiValue,
"days",
_("Days of Week"),
_("Select active days. If none selected, the rule applies every day."),
);
o.modalonly = true;
o.rmempty = true;
Object.entries(DAY_LABELS).forEach(([key, label]) => {
o.value(key, label);
});
}
const EntryPoint = {
createParentalContent,
};
return baseclass.extend(EntryPoint);
@@ -1820,9 +1820,7 @@ function showServerInfoModal(sectionId) {
}
function getServerEditButtonText() {
const label = _("Edit rule action");
return label === "Edit rule action" ? "Edit" : label;
return _("Edit");
}
function renderServerRowActions(sectionRef, sectionId) {
@@ -15,6 +15,9 @@
// Server
"require view.tachyon.server as server";
// Parental Control
"require view.tachyon.parental as parental";
// Dashboard
"require view.tachyon.dashboard as dashboard";
@@ -65,9 +68,7 @@ function renderSectionAdd(sectionRef, extra_class) {
}
function getRuleEditButtonText() {
const label = _("Edit rule action");
return label === "Edit rule action" ? "Edit" : label;
return _("Edit");
}
function configureGridSection(sectionRef, type, title, addTitle) {
@@ -455,6 +456,20 @@ const EntryPoint = {
});
server.createServerContent(serverSection, uiCapabilities);
const parentalSection = tachyonMap.section(
form.GridSection,
"schedule",
_("Parental Control"),
_("Block internet access or specific proxy sections for individual devices based on time and days."),
);
configureGridSection(
parentalSection,
"schedule",
_("Schedule Rule"),
_("Add a schedule rule"),
);
parental.createParentalContent(parentalSection);
const settingsSection = tachyonMap.section(
form.TypedSection,
"settings",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51
View File
@@ -1731,9 +1731,57 @@ function validate_runtime_mark_ranges_context(context) {
}
}
function validate_time_format(val, label) {
val = trim(as_string(val));
if (val == "") return;
if (!match(val, /^([01][0-9]|2[0-3]):[0-5][0-9]$/))
fail_validation("Schedule option '" + label + "' has invalid time format '" + val + "'. Expected HH:MM. Aborted.");
}
function validate_schedule(schedule, sections) {
if (!section_enabled(schedule))
return;
let name = section_name(schedule);
let start_time = option(schedule, "start_time", "");
let end_time = option(schedule, "end_time", "");
validate_time_format(start_time, "schedule." + name + ".start_time");
validate_time_format(end_time, "schedule." + name + ".end_time");
let raw_ips = list_option(schedule, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
for (let ip in raw_ips) {
let clean_ip = trim(as_string(ip));
if (clean_ip != "" && !core_ip.is_ip_or_cidr(clean_ip))
fail_validation("Schedule '" + name + "' has invalid device IP '" + clean_ip + "'. Aborted.");
}
let target = option(schedule, "target", "all");
let sec_names = list_option(schedule, "sections");
if (length(sec_names) == 0) {
let single_sec = option(schedule, "sections", "");
if (single_sec != "") sec_names = [ single_sec ];
else if (target != "all" && target != "sections" && target != "") sec_names = [ target ];
}
for (let sec_name in sec_names) {
let found = false;
for (let sec in sections) {
if (section_name(sec) == sec_name) {
found = true;
break;
}
}
if (!found)
fail_validation("Schedule '" + name + "' references unknown section '" + sec_name + "'. Aborted.");
}
}
function validate_runtime_config(context) {
let settings = settings_section();
let sections = sections_by_type("section");
let schedules = sections_by_type("schedule");
validate_runtime_mark_ranges_context(context);
validate_dns_settings(settings, sections, context);
@@ -1765,6 +1813,9 @@ function validate_runtime_config(context) {
for (let section in sections)
validate_rule(section, sections, context);
for (let schedule in schedules)
validate_schedule(schedule, sections);
}
function context_from_runtime() {
+221 -15
View File
@@ -52,6 +52,14 @@ function uci_settings() {
return uci_section("settings");
}
function section_by_name(sections, section_name) {
section_name = as_string(section_name);
for (let section in sections)
if (as_string(section[".name"]) == section_name)
return section;
return null;
}
function write_compact_string_array(values) {
print("[");
for (let i = 0; i < length(values); i++) {
@@ -855,6 +863,176 @@ function nft_add_section_priority_rules_from_sections(sections, table, interface
return true;
}
function normalize_schedule_day_name(day) {
let d = lc(trim(as_string(day)));
let map = {
"mon": "Monday", "monday": "Monday", "1": "Monday",
"tue": "Tuesday", "tuesday": "Tuesday", "2": "Tuesday",
"wed": "Wednesday", "wednesday": "Wednesday", "3": "Wednesday",
"thu": "Thursday", "thursday": "Thursday", "4": "Thursday",
"fri": "Friday", "friday": "Friday", "5": "Friday",
"sat": "Saturday", "saturday": "Saturday", "6": "Saturday",
"sun": "Sunday", "sunday": "Sunday", "7": "Sunday", "0": "Sunday"
};
return map[d] || null;
}
function nft_schedule_days_match_args(schedule) {
let raw_days = list_option(schedule, "days");
if (length(raw_days) == 0)
return [];
let days_set = {};
for (let day in raw_days) {
let norm = normalize_schedule_day_name(day);
if (norm) days_set[norm] = true;
}
let day_names = sort(keys(days_set));
if (length(day_names) == 0 || length(day_names) == 7)
return [];
if (length(day_names) == 1)
return [ "meta", "day", day_names[0] ];
return [ "meta", "day", "{ " + join(", ", day_names) + " }" ];
}
function nft_schedule_time_intervals(start_time, end_time) {
start_time = trim(as_string(start_time));
end_time = trim(as_string(end_time));
if (start_time == "" || end_time == "")
return [ [ "00:00:00", "23:59:59" ] ];
if (length(start_time) == 5) start_time += ":00";
if (length(end_time) == 5) end_time += ":00";
if (start_time > end_time) {
// Crosses midnight, e.g. 22:00:00 to 08:00:00
return [
[ start_time, "23:59:59" ],
[ "00:00:00", end_time ]
];
}
return [ [ start_time, end_time ] ];
}
function nft_add_schedule_rules_from_schedules(schedules, sections, table) {
for (let schedule in schedules) {
schedule = object_or_empty(schedule);
if (!bool_option(schedule, "enabled", true))
continue;
let raw_ips = list_option(schedule, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
if (length(raw_ips) == 0)
continue;
let start_time = option(schedule, "start_time", "");
let end_time = option(schedule, "end_time", "");
let intervals = nft_schedule_time_intervals(start_time, end_time);
let days_args = nft_schedule_days_match_args(schedule);
let target = option(schedule, "target", "all");
let action = option(schedule, "action", "block");
let verdict = action == "allow" ? "accept" : "drop";
let target_sec_names = [];
if (target == "sections" || (target != "all" && target != "")) {
let raw_secs = list_option(schedule, "sections");
if (length(raw_secs) == 0) {
let single_sec = option(schedule, "sections", "");
if (single_sec != "") raw_secs = [ single_sec ];
else if (target != "sections") raw_secs = [ target ];
}
target_sec_names = raw_secs;
}
for (let raw_ip in raw_ips) {
let ip_str = trim(as_string(raw_ip));
if (ip_str == "") continue;
let family = core_ip.ip_family(ip_str);
let saddr_key = family == 6 ? "ip6" : "ip";
for (let interval in intervals) {
let time_args = [ "meta", "hour", sprintf("\"%s\"-\"%s\"", interval[0], interval[1]) ];
let base_match = [ saddr_key, "saddr", ip_str ];
append_array(base_match, days_args);
append_array(base_match, time_args);
if (target == "all" || (length(target_sec_names) == 0 && target != "sections")) {
let fwd_rule = [];
append_array(fwd_rule, base_match);
append_array(fwd_rule, [ "counter", verdict ]);
nft_add_rule(table, "parental_forward", fwd_rule);
let ctrl_rule = [];
append_array(ctrl_rule, base_match);
append_array(ctrl_rule, [ "counter", verdict ]);
nft_add_rule(table, "parental_control", ctrl_rule);
} else {
for (let sec_name in target_sec_names) {
let target_section = section_by_name(sections, sec_name);
if (!target_section) continue;
let sets = section_priority_sets(target_section);
if (!sets) continue;
if (sets.subnets) {
let sub_rule = [];
append_array(sub_rule, base_match);
append_array(sub_rule, [ "ip", "daddr", "@" + as_string(sets.subnets), "counter", verdict ]);
nft_add_rule(table, "parental_control", sub_rule);
nft_add_rule(table, "parental_forward", sub_rule);
}
if (sets.subnets6 && family == 6) {
let sub6_rule = [];
append_array(sub6_rule, base_match);
append_array(sub6_rule, [ "ip6", "daddr", "@" + as_string(sets.subnets6), "counter", verdict ]);
nft_add_rule(table, "parental_control", sub6_rule);
nft_add_rule(table, "parental_forward", sub6_rule);
}
if (sets.ip_ports) {
let ipport_tcp = [];
append_array(ipport_tcp, base_match);
append_array(ipport_tcp, [ "ip", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip_ports), "counter", verdict ]);
nft_add_rule(table, "parental_control", ipport_tcp);
nft_add_rule(table, "parental_forward", ipport_tcp);
let ipport_udp = [];
append_array(ipport_udp, base_match);
append_array(ipport_udp, [ "ip", "daddr", ".", "udp", "dport", "@" + as_string(sets.ip_ports), "counter", verdict ]);
nft_add_rule(table, "parental_control", ipport_udp);
nft_add_rule(table, "parental_forward", ipport_udp);
}
if (sets.ports) {
let port_tcp = [];
append_array(port_tcp, base_match);
append_array(port_tcp, [ "tcp", "dport", "@" + as_string(sets.ports), "counter", verdict ]);
nft_add_rule(table, "parental_control", port_tcp);
nft_add_rule(table, "parental_forward", port_tcp);
let port_udp = [];
append_array(port_udp, base_match);
append_array(port_udp, [ "udp", "dport", "@" + as_string(sets.ports), "counter", verdict ]);
nft_add_rule(table, "parental_control", port_udp);
nft_add_rule(table, "parental_forward", port_udp);
}
}
}
}
}
}
return true;
}
function nft_add_schedule_rules_from_uci(table, sections) {
return nft_add_schedule_rules_from_schedules(uci_sections("schedule"), sections, table);
}
function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_port_set, interface_set, source_interfaces, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, exclude_ntp, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) {
localv6_set = default_arg(localv6_set, "localv6");
common6_set = default_arg(common6_set, "tachyon_subnets6");
@@ -880,12 +1058,22 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
return false;
if (!nft_create_chain(table, "mangle", "{ type filter hook prerouting priority -149; policy accept; }") ||
!nft_create_chain(table, "dstnat", "{ type nat hook prerouting priority dstnat; policy accept; }") ||
!nft_create_chain(table, "mangle_output", "{ type route hook output priority -150; policy accept; }") ||
!nft_create_priority_chains(table) ||
!nft_create_chain(table, "parental_control", "{ }") ||
!nft_create_chain(table, "parental_forward", "{ }") ||
!nft_create_chain(table, "proxy", "{ type filter hook prerouting priority -100; policy accept; }"))
return false;
if (!nft_add_rule(table, "dstnat", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "!=", "@" + as_string(localv4_set), "udp", "dport", "53", "redirect", "to", ":53" ]) ||
!nft_add_rule(table, "dstnat", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "!=", "@" + as_string(localv4_set), "tcp", "dport", "53", "redirect", "to", ":53" ]) ||
!nft_add_rule(table, "dstnat", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "!=", "@" + as_string(localv6_set), "udp", "dport", "53", "redirect", "to", ":53" ]) ||
!nft_add_rule(table, "dstnat", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "!=", "@" + as_string(localv6_set), "tcp", "dport", "53", "redirect", "to", ":53" ]))
return false;
if (!nft_add_rule(table, "mangle", [ "ct", "status", "dnat", "return" ]) ||
!nft_add_rule(table, "mangle", [ "jump", "parental_control" ]) ||
!nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(localv4_set), "return" ]) ||
!nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "@" + as_string(localv6_set), "return" ]))
return false;
@@ -946,7 +1134,8 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
!nft_add_rule(table, "mangle_output", [ "jump", "priority_output_rules" ]))
return false;
if (!nft_create_chain(table, "mangle_forward", "{ type filter hook forward priority -150; policy accept; }"))
if (!nft_create_chain(table, "mangle_forward", "{ type filter hook forward priority -150; policy accept; }") ||
!nft_add_rule(table, "mangle_forward", [ "jump", "parental_forward" ]))
return false;
let rtmtu_ok = nft_add_rule(table, "mangle_forward", [ "tcp", "flags", "syn", "tcp", "option", "maxseg", "size", "set", "rt", "mtu" ]);
@@ -1633,7 +1822,20 @@ function nft_rule_signature_body(body, section) {
return body;
}
function nft_runtime_signature_from_settings_and_sections(settings, sections) {
function nft_schedule_signature_body(body, schedule) {
let name = as_string(schedule[".name"]);
body = signature_add_value(body, "schedule." + name + ".enabled", bool_option(schedule, "enabled", true) ? "1" : "0");
body = signature_add_value(body, "schedule." + name + ".device_ip", option(schedule, "device_ip", ""));
body = signature_add_value(body, "schedule." + name + ".target", option(schedule, "target", "all"));
body = signature_add_value(body, "schedule." + name + ".sections", join(",", list_option(schedule, "sections")));
body = signature_add_value(body, "schedule." + name + ".action", option(schedule, "action", "block"));
body = signature_add_value(body, "schedule." + name + ".start_time", option(schedule, "start_time", ""));
body = signature_add_value(body, "schedule." + name + ".end_time", option(schedule, "end_time", ""));
body = signature_add_value(body, "schedule." + name + ".days", join(",", list_option(schedule, "days")));
return body;
}
function nft_runtime_signature_from_settings_and_sections(settings, sections, schedules) {
let body = "";
body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan"));
@@ -1644,11 +1846,14 @@ function nft_runtime_signature_from_settings_and_sections(settings, sections) {
for (let section in sections)
body = nft_rule_signature_body(body, object_or_empty(section));
for (let schedule in schedules)
body = nft_schedule_signature_body(body, object_or_empty(schedule));
return signature_hash(body);
}
function print_nft_runtime_signature_from_settings_and_sections(settings, sections) {
let hash = nft_runtime_signature_from_settings_and_sections(settings, sections);
function print_nft_runtime_signature_from_settings_and_sections(settings, sections, schedules) {
let hash = nft_runtime_signature_from_settings_and_sections(settings, sections, schedules);
if (hash == "")
return false;
@@ -1674,14 +1879,6 @@ function fixture_section_list(data, type_name) {
return type(plural) == "array" ? plural : [];
}
function section_by_name(sections, section_name) {
section_name = as_string(section_name);
for (let section in sections)
if (as_string(section[".name"]) == section_name)
return section;
return null;
}
function nft_create_provider_output_rules_from_uci(table, action, provider_bin, route_mark_base, queue_base, desync_mark, desync_mark_postnat) {
return nft_create_provider_output_rules_from_sections(
uci_sections("section"),
@@ -1698,9 +1895,11 @@ function nft_create_provider_output_rules_from_uci(table, action, provider_bin,
function nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) {
log_debug("Building nftables runtime model");
return ensure_tproxy_route_rule(rt_table, fakeip_mark) &&
return ensure_bridge_netfilter_disabled() &&
ensure_tproxy_route_rule(rt_table, fakeip_mark) &&
nft_create_runtime_base_from_uci(table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) &&
nft_add_section_priority_rules_from_sections(uci_sections("section"), table, interface_set, localv4_set, localv6_set, fakeip_mark) &&
nft_add_schedule_rules_from_uci(table, uci_sections("section")) &&
nft_create_provider_output_rules_from_uci(table, "zapret", zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat) &&
nft_create_provider_output_rules_from_uci(table, "zapret2", zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat) &&
nft_create_runtime_output_rules(table, localv4_set, common_set, port_set, ip_port_set, fakeip_mark, fakeip_range, localv6_set, common6_set, ip_port6_set, fakeip6_range);
@@ -1726,7 +1925,8 @@ function nft_rebuild_runtime_from_uci(rt_table, table, localv4_set, common_set,
function nft_runtime_signature_from_uci() {
return print_nft_runtime_signature_from_settings_and_sections(
uci_settings(),
uci_sections("section")
uci_sections("section"),
uci_sections("schedule")
);
}
@@ -1743,7 +1943,11 @@ function fixture_settings(data) {
function nft_runtime_signature_from_fixture(path) {
let data = object_or_empty(common_read_json_file(path));
connections.set_item_sections_from_data(data);
return print_nft_runtime_signature_from_settings_and_sections(fixture_settings(data), fixture_section_list(data, "section"));
return print_nft_runtime_signature_from_settings_and_sections(
fixture_settings(data),
fixture_section_list(data, "section"),
fixture_section_list(data, "schedule")
);
}
function nft_mangle_chain_text(context, table) {
@@ -1980,6 +2184,8 @@ else if (mode == "nft-create-provider-output-rules-fixture")
exit(nft_create_provider_output_rules_from_sections(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]) ? 0 : 1);
else if (mode == "nft-add-section-priority-rules-fixture")
exit(nft_add_section_priority_rules_from_sections(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1);
else if (mode == "nft-add-schedule-rules-fixture")
exit(nft_add_schedule_rules_from_schedules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "schedule"), fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2]) ? 0 : 1);
else if (mode == "nft-create-full-runtime-from-uci")
exit(nft_create_full_runtime_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17], ARGV[18], ARGV[19], ARGV[20], ARGV[21], ARGV[22], ARGV[23], ARGV[24], ARGV[25], ARGV[26]) ? 0 : 1);
else if (mode == "nft-rebuild-runtime-from-uci")
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
NFT_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/nft/apply.uc"
VALIDATOR_RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/config/validator.uc"
WORK_DIR="$(mktemp -d)"
NFT_LOG="$WORK_DIR/nft.log"
nft_ucode() {
ucode -L "$TACHYON_LIB" "$NFT_RUNTIME" "$@"
}
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
cat "$NFT_LOG" >&2 2>/dev/null || true
exit 1
}
assert_contains() {
local file="$1"
local expected="$2"
local label="$3"
grep -Fq "$expected" "$file" || fail "$label: expected '$expected'"
}
mkdir -p "$WORK_DIR/bin"
cat >"$WORK_DIR/bin/nft" <<NFT
#!/usr/bin/env bash
set -eo pipefail
{
printf 'nft'
for arg in "\$@"; do
printf '\t%s' "\$arg"
done
printf '\n'
} >>"$NFT_LOG"
exit 0
NFT
chmod +x "$WORK_DIR/bin/nft"
export PATH="$WORK_DIR/bin:$PATH"
cat >"$WORK_DIR/schedule_fixture.json" <<'JSON'
{
"section": [
{
".name": "sec_youtube",
"enabled": "1",
"action": "connection",
"ip_cidr": [ "142.250.0.0/15" ]
},
{
".name": "sec_discord",
"enabled": "1",
"action": "connection",
"ip_cidr": [ "162.158.0.0/16" ]
}
],
"schedule": [
{
".name": "phone_night",
"enabled": "1",
"device_ip": "192.168.1.150",
"target": "all",
"action": "block",
"start_time": "22:00",
"end_time": "08:00",
"days": [ "mon", "tue", "wed", "thu", "fri" ]
},
{
".name": "tv_multi",
"enabled": "1",
"device_ip": "192.168.1.160",
"target": "sections",
"sections": [ "sec_youtube", "sec_discord" ],
"action": "block",
"start_time": "21:00",
"end_time": "07:00",
"days": [ "sat", "sun" ]
}
]
}
JSON
# Run nft-add-schedule-rules-fixture
nft_ucode nft-add-schedule-rules-fixture "$WORK_DIR/schedule_fixture.json" "tachyon"
# Verify rules in NFT_LOG
assert_contains "$NFT_LOG" "parental_forward ip saddr 192.168.1.150 meta day { Friday, Monday, Thursday, Tuesday, Wednesday } meta hour \"22:00:00\"-\"23:59:59\" counter drop" "midnight wrap part 1"
assert_contains "$NFT_LOG" "parental_forward ip saddr 192.168.1.150 meta day { Friday, Monday, Thursday, Tuesday, Wednesday } meta hour \"00:00:00\"-\"08:00:00\" counter drop" "midnight wrap part 2"
assert_contains "$NFT_LOG" "parental_control ip saddr 192.168.1.150 meta day { Friday, Monday, Thursday, Tuesday, Wednesday } meta hour \"22:00:00\"-\"23:59:59\" counter drop" "parental_control drop"
# Verify per-section blocking
assert_contains "$NFT_LOG" "ip saddr 192.168.1.160 meta day { Saturday, Sunday } meta hour \"21:00:00\"-\"23:59:59\" ip daddr @tachyon_rule_sec_youtube_subnets counter drop" "section youtube subnet drop"
assert_contains "$NFT_LOG" "ip saddr 192.168.1.160 meta day { Saturday, Sunday } meta hour \"21:00:00\"-\"23:59:59\" ip daddr @tachyon_rule_sec_discord_subnets counter drop" "section discord subnet drop"
# Verify signature changes when schedule is modified
SIG1="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/schedule_fixture.json")"
cat >"$WORK_DIR/schedule_fixture2.json" <<'JSON'
{
"section": [
{
".name": "sec_youtube",
"enabled": "1",
"action": "connection"
}
],
"schedule": [
{
".name": "phone_night",
"enabled": "0",
"device_ip": "192.168.1.150",
"target": "all"
}
]
}
JSON
SIG2="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/schedule_fixture2.json")"
[ "$SIG1" != "$SIG2" ] || fail "Signature must change when schedule is updated"
# Verify validator rejects invalid schedule time
cat >"$WORK_DIR/invalid_time.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"schedule": [
{
".name": "bad_time",
"enabled": "1",
"device_ip": "192.168.1.100",
"start_time": "25:00"
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_time.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject invalid start_time 25:00"
fi
# Verify validator rejects invalid schedule device IP
cat >"$WORK_DIR/invalid_ip.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"schedule": [
{
".name": "bad_ip",
"enabled": "1",
"device_ip": "999.999.999.999",
"start_time": "22:00",
"end_time": "08:00"
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_ip.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject invalid device_ip 999.999.999.999"
fi
# Verify validator rejects unknown target section
cat >"$WORK_DIR/invalid_target.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"schedule": [
{
".name": "bad_target",
"enabled": "1",
"device_ip": "192.168.1.100",
"target": "non_existent_section"
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_target.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject non-existent target section"
fi
printf 'Parental control and schedule tests passed\n'
+1 -1
View File
@@ -7,7 +7,7 @@
set -u
UNINSTALLER_VERSION="1.2.85"
UNINSTALLER_VERSION="1.2.86"
# ─── TUI helpers & Color detection ───────────────────────────────────────────
ESC="$(printf '\033')"