feat: родительский контроль по расписанию, мультивыбор секций, перехват DNS и обновление локализации (1.2.86)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+2113
-49
File diff suppressed because it is too large
Load Diff
+1487
-23
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",
|
||||
|
||||
+614
-239
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
UNINSTALLER_VERSION="1.2.85"
|
||||
UNINSTALLER_VERSION="1.2.86"
|
||||
|
||||
# ─── TUI helpers & Color detection ───────────────────────────────────────────
|
||||
ESC="$(printf '\033')"
|
||||
|
||||
Reference in New Issue
Block a user