feat: add Guest Mode isolation with quotas and fix Telegram bot language switching

This commit is contained in:
Dushnilin
2026-09-08 09:10:00 +03:00
parent 203bb9b433
commit d07e2c599f
11 changed files with 1149 additions and 52 deletions
@@ -1297,9 +1297,183 @@ function createParentalContent(section) {
o.rmempty = false;
}
// ─── Guest Mode Section ───────────────────────────────────────────────────────
function createGuestModeContent(section) {
// Enabled switch
let o = section.option(form.Flag, "enabled", _("Enable Guest Mode"));
o.default = "0";
o.rmempty = false;
// Policy Mode: selected vs inverted
o = section.option(
form.ListValue,
"mode",
_("Policy Mode"),
_(
"Selected: specified devices become guests. Inverted: all devices on the network become guests EXCEPT the trusted ones (Zero-Trust).",
),
);
o.default = "selected";
o.value("selected", _("Selected Devices are Guests (Explicit list)"));
o.value("inverted", _("All Devices are Guests EXCEPT Trusted (Zero-Trust)"));
// Guest devices (for selected mode)
o = section.option(
form.DynamicList,
"guest_devices",
_("Guest Devices (MAC / IP)"),
_(
"Devices that are isolated as guests. Enter IP or MAC addresses, or pick from active LAN devices.",
),
);
o.rmempty = true;
o.placeholder = "192.168.1.150 or AA:BB:CC:DD:EE:FF";
o.validate = validateDevice;
o.depends("mode", "selected");
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
return local_devices.createLocalDeviceDynamicListWidget(
this,
sectionId,
cfgvalue,
);
};
// Trusted devices (for inverted mode)
o = section.option(
form.DynamicList,
"trusted_devices",
_("Trusted Master Devices (MAC / IP)"),
_(
"Master and family devices with full LAN and router access. Any device NOT in this list will be treated as a guest.",
),
);
o.rmempty = true;
o.placeholder = "192.168.1.100 or AA:BB:CC:DD:EE:FF";
o.validate = validateDevice;
o.depends("mode", "inverted");
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
return local_devices.createLocalDeviceDynamicListWidget(
this,
sectionId,
cfgvalue,
);
};
// LAN Isolation
o = section.option(
form.Flag,
"isolate_lan",
_("Isolate Local LAN"),
_(
"Prevent guest devices from accessing other local network devices (RFC1918 and IPv6 local subnets).",
),
);
o.default = "1";
o.rmempty = false;
// Block Router Admin
o = section.option(
form.Flag,
"block_router_admin",
_("Block Router Administration"),
_(
"Prevent guest devices from accessing router management services (LuCI Web UI, SSH, Clash API). DHCP and DNS remain accessible.",
),
);
o.default = "1";
o.rmempty = false;
// Daily Time Quota (minutes)
o = section.option(
form.Value,
"daily_time_limit",
_("Daily Time Limit (minutes)"),
_(
"Maximum allowed online time per guest device per day. When reached, access is blocked until midnight. Enter 0 for unlimited.",
),
);
o.default = "0";
o.placeholder = "0";
o.rmempty = true;
o.validate = function (_sectionId, value) {
if (value === "" || value === null || value === undefined) return true;
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > 1440)
return _("Enter a whole number of minutes between 0 and 1440");
return true;
};
// Daily Traffic Quota (MB)
o = section.option(
form.Value,
"daily_traffic_limit",
_("Daily Traffic Limit (MB)"),
_(
"Maximum allowed download and upload traffic per guest device per day in megabytes. Enter 0 for unlimited.",
),
);
o.default = "0";
o.placeholder = "0";
o.rmempty = true;
o.validate = function (_sectionId, value) {
if (value === "" || value === null || value === undefined) return true;
const n = Number(value);
if (!Number.isInteger(n) || n < 0)
return _("Enter a positive whole number in megabytes");
return true;
};
// Schedule Active Window (start_time, end_time)
o = section.option(
form.Value,
"start_time",
_("Allowed Start Time"),
_("Start of allowed daily access window in HH:MM format (24-hour clock)."),
);
o.placeholder = "08:00";
o.rmempty = true;
o.validate = validateTime;
o = section.option(
form.Value,
"end_time",
_("Allowed End Time"),
_("End of allowed daily access window in HH:MM format (24-hour clock)."),
);
o.placeholder = "22:00";
o.rmempty = true;
o.validate = validateTime;
// Days of week
o = section.option(
form.MultiValue,
"days",
_("Allowed Days of Week"),
_("Select days when guest access is permitted. Leave empty for all days."),
);
o.rmempty = true;
Object.entries(DAY_LABELS).forEach(([val, label]) => {
o.value(val, label);
});
// Telegram notification
o = section.option(
form.Flag,
"notify",
_("Telegram Alerts"),
_(
"Send Telegram notification when a guest device hits its daily time or traffic quota.",
),
);
o.default = "1";
o.rmempty = false;
}
const EntryPoint = {
createProfileContent,
createParentalContent,
createGuestModeContent,
};
return baseclass.extend(EntryPoint);
@@ -1015,6 +1015,13 @@ function createMenuTabsOrderWidget(option, section_id) {
defaultVisible: false,
fixed: false,
},
{
id: "guest_mode",
label: _("Guest Mode"),
desc: _("Guest network isolation and device quotas"),
defaultVisible: true,
fixed: false,
},
{
id: "monitoring",
label: _("Monitoring"),
@@ -443,6 +443,7 @@ const EntryPoint = {
"server",
"profile",
"schedule",
"guest_mode",
"monitoring",
"diagnostic",
"updates",
@@ -575,6 +576,21 @@ const EntryPoint = {
parental.createParentalContent(s);
return s;
},
guest_mode: () => {
const s = tachyonMap.section(
form.NamedSection,
"guest_mode",
"guest_mode",
_("Guest Mode"),
_(
"Guest mode isolates guest devices from local LAN and router administration, while allowing internet access with daily quotas and schedules.",
),
);
s.anonymous = false;
s.addremove = false;
parental.createGuestModeContent(s);
return s;
},
monitoring: () => {
const s = tachyonMap.section(
form.TypedSection,
+18
View File
@@ -291,4 +291,22 @@ config telegram 'telegram'
# list days 'thursday'
# list days 'friday'
config guest_mode 'guest_mode'
option enabled '0'
option mode 'selected'
# mode: 'selected' (explicit guest_devices) or 'inverted' (all devices except trusted_devices)
#list guest_devices '192.168.1.201'
#list guest_devices 'AA:BB:CC:DD:EE:01'
#list trusted_devices '192.168.1.100'
#list trusted_devices 'AA:BB:CC:DD:EE:FE'
option isolate_lan '1'
option block_router_admin '1'
option daily_time_limit '0'
option daily_traffic_limit '0'
#option start_time '08:00'
#option end_time '23:00'
#list days 'monday'
option notify '1'
+49
View File
@@ -1864,11 +1864,57 @@ function validate_profile(profile, profiles) {
}
}
function validate_guest_mode(guest_mode) {
if (!section_enabled(guest_mode))
return;
let name = section_name(guest_mode);
let mode = option(guest_mode, "mode", "selected");
if (mode != "selected" && mode != "inverted")
fail_validation("Guest mode '" + name + "' has invalid mode '" + mode + "'. Expected 'selected' or 'inverted'. Aborted.");
let guest_devs = list_option(guest_mode, "guest_devices");
for (let dev in guest_devs) {
let clean = trim(as_string(dev));
let is_mac = match(clean, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (clean != "" && !core_ip.valid_ip_or_cidr(clean) && !is_mac)
fail_validation("Guest mode '" + name + "' has invalid guest device '" + clean + "'. Aborted.");
}
let trusted_devs = list_option(guest_mode, "trusted_devices");
for (let dev in trusted_devs) {
let clean = trim(as_string(dev));
let is_mac = match(clean, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (clean != "" && !core_ip.valid_ip_or_cidr(clean) && !is_mac)
fail_validation("Guest mode '" + name + "' has invalid trusted device '" + clean + "'. Aborted.");
}
let time_limit_raw = trim(as_string(option(guest_mode, "daily_time_limit", "") || ""));
if (time_limit_raw != "") {
let limit = int(time_limit_raw);
if (limit < 0 || limit > 1440 || match(time_limit_raw, /^[0-9]+$/) == null)
fail_validation("Guest mode '" + name + "' has invalid daily_time_limit '" + time_limit_raw + "'. Use a number of minutes between 0 and 1440. Aborted.");
}
let traffic_limit_raw = trim(as_string(option(guest_mode, "daily_traffic_limit", "") || ""));
if (traffic_limit_raw != "") {
let limit = int(traffic_limit_raw);
if (limit < 0 || match(traffic_limit_raw, /^[0-9]+$/) == null)
fail_validation("Guest mode '" + name + "' has invalid daily_traffic_limit '" + traffic_limit_raw + "'. Use a positive number in megabytes. Aborted.");
}
let start_time = option(guest_mode, "start_time", "");
let end_time = option(guest_mode, "end_time", "");
validate_time_format(start_time, "guest_mode." + name + ".start_time");
validate_time_format(end_time, "guest_mode." + name + ".end_time");
}
function validate_runtime_config(context) {
let settings = settings_section();
let sections = sections_by_type("section");
let schedules = sections_by_type("schedule");
let profiles = sections_by_type("profile");
let guest_modes = sections_by_type("guest_mode");
validate_runtime_mark_ranges_context(context);
@@ -1925,6 +1971,9 @@ function validate_runtime_config(context) {
for (let schedule in schedules)
validate_schedule(schedule, sections, profiles);
for (let guest_mode in guest_modes)
validate_guest_mode(guest_mode);
}
function context_from_runtime() {
+212 -5
View File
@@ -439,6 +439,10 @@ function nft_create_ifname_set(table, name) {
return nft_create_set(table, name, "{ type ifname; flags interval; }");
}
function nft_create_ether_set(table, name) {
return nft_create_set(table, name, "{ type ether_addr; flags interval; }");
}
function nft_add_set_elements(table, set_name, elements) {
let stamp = clock();
let tmp_path = sprintf("/tmp/nft_elements.%d.%d.tmp", stamp[0], stamp[1]);
@@ -1227,6 +1231,180 @@ function nft_add_dns_block_rules_from_uci(table) {
return nft_add_dns_block_rules_from_schedules(uci_sections("schedule"), table, uci_sections("profile"));
}
// ─── Guest Mode (LAN Isolation & Protection) ─────────────────────────────────
// Guest devices can only access WAN (Internet). Access to local private subnets
// (RFC1918 & IPv6 local ranges) and router administration ports is blocked.
// Mode 'selected': explicitly listed guest_devices are restricted.
// Mode 'inverted': all LAN devices are guests EXCEPT trusted_devices.
function nft_add_guest_mode_rules(guest_sections, table, interface_set, localv4_set, localv6_set) {
if (length(guest_sections) == 0)
return true;
let s = object_or_empty(guest_sections[0]);
if (!bool_option(s, "enabled", false))
return true;
let mode = option(s, "mode", "selected");
let isolate_lan = bool_option(s, "isolate_lan", true);
let block_router_admin = bool_option(s, "block_router_admin", true);
// Create guest_input chain for router admin protection (hook input priority -140)
if (!nft_create_chain(table, "guest_input", "{ type filter hook input priority -140; policy accept; }"))
return false;
// Essential network services: guests must be able to get DHCP and resolve DNS
nft_add_rule(table, "guest_input", [ "udp", "dport", "{ 67, 68 }", "accept" ]);
nft_add_rule(table, "guest_input", [ "udp", "dport", "53", "accept" ]);
nft_add_rule(table, "guest_input", [ "tcp", "dport", "53", "accept" ]);
nft_add_rule(table, "guest_input", [ "icmp", "type", "echo-request", "accept" ]);
nft_add_rule(table, "guest_input", [ "icmpv6", "type", "{ echo-request, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert }", "accept" ]);
let guest_macs = [];
let guest_ips = [];
let guest_ip6s = [];
if (mode == "inverted") {
// Mode 2: Inverted (All LAN devices are guests EXCEPT trusted devices)
let trusted_devs = list_option(s, "trusted_devices");
let trusted_macs = [];
let trusted_ips = [];
let trusted_ip6s = [];
for (let dev in trusted_devs) {
dev = trim(as_string(dev));
if (dev == "") continue;
if (match(dev, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/)) {
push(trusted_macs, lc(replace(dev, "-", ":")));
} else if (core_ip.ip_family(dev) == 6) {
push(trusted_ip6s, dev);
} else {
push(trusted_ips, dev);
}
}
if (length(trusted_macs) > 0) {
nft_create_ether_set(table, "tachyon_trusted_mac");
nft_add_set_elements(table, "tachyon_trusted_mac", join(",", trusted_macs));
nft_add_rule(table, "guest_input", [ "ether", "saddr", "@tachyon_trusted_mac", "return" ]);
nft_add_rule(table, "guest_forward", [ "ether", "saddr", "@tachyon_trusted_mac", "return" ]);
}
if (length(trusted_ips) > 0) {
nft_create_ipv4_set(table, "tachyon_trusted_ip");
nft_add_set_elements(table, "tachyon_trusted_ip", join(",", trusted_ips));
nft_add_rule(table, "guest_input", [ "ip", "saddr", "@tachyon_trusted_ip", "return" ]);
nft_add_rule(table, "guest_forward", [ "ip", "saddr", "@tachyon_trusted_ip", "return" ]);
}
if (length(trusted_ip6s) > 0) {
nft_create_ipv6_set(table, "tachyon_trusted_ip6");
nft_add_set_elements(table, "tachyon_trusted_ip6", join(",", trusted_ip6s));
nft_add_rule(table, "guest_input", [ "ip6", "saddr", "@tachyon_trusted_ip6", "return" ]);
nft_add_rule(table, "guest_forward", [ "ip6", "saddr", "@tachyon_trusted_ip6", "return" ]);
}
// All non-trusted LAN devices:
// 1. Block access to router management ports
if (block_router_admin) {
nft_add_rule(table, "guest_input", [ "iifname", "@" + as_string(interface_set), "counter", "drop", "comment", "\"tachyon-guest-admin-drop\"" ]);
}
// 2. Block access to private LAN subnets
if (isolate_lan) {
nft_add_rule(table, "guest_forward", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(localv4_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
nft_add_rule(table, "guest_forward", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "@" + as_string(localv6_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
}
// 3. Traffic accounting
nft_add_rule(table, "guest_forward", [ "iifname", "@" + as_string(interface_set), "counter", "comment", "\"tachyon-guest-traffic\"" ]);
} else {
// Mode 1: Selected (Explicitly listed guest devices)
let guest_devs = list_option(s, "guest_devices");
for (let dev in guest_devs) {
dev = trim(as_string(dev));
if (dev == "") continue;
if (match(dev, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/)) {
push(guest_macs, lc(replace(dev, "-", ":")));
} else if (core_ip.ip_family(dev) == 6) {
push(guest_ip6s, dev);
} else {
push(guest_ips, dev);
}
}
if (length(guest_macs) > 0) {
nft_create_ether_set(table, "tachyon_guest_mac");
nft_add_set_elements(table, "tachyon_guest_mac", join(",", guest_macs));
}
if (length(guest_ips) > 0) {
nft_create_ipv4_set(table, "tachyon_guest_ip");
nft_add_set_elements(table, "tachyon_guest_ip", join(",", guest_ips));
}
if (length(guest_ip6s) > 0) {
nft_create_ipv6_set(table, "tachyon_guest_ip6");
nft_add_set_elements(table, "tachyon_guest_ip6", join(",", guest_ip6s));
}
// 1. Block access to router management ports
if (block_router_admin) {
if (length(guest_macs) > 0)
nft_add_rule(table, "guest_input", [ "ether", "saddr", "@tachyon_guest_mac", "counter", "drop", "comment", "\"tachyon-guest-admin-drop\"" ]);
if (length(guest_ips) > 0)
nft_add_rule(table, "guest_input", [ "ip", "saddr", "@tachyon_guest_ip", "counter", "drop", "comment", "\"tachyon-guest-admin-drop\"" ]);
if (length(guest_ip6s) > 0)
nft_add_rule(table, "guest_input", [ "ip6", "saddr", "@tachyon_guest_ip6", "counter", "drop", "comment", "\"tachyon-guest-admin-drop\"" ]);
}
// 2. Block access to local LAN subnets
if (isolate_lan) {
if (length(guest_macs) > 0) {
nft_add_rule(table, "guest_forward", [ "ether", "saddr", "@tachyon_guest_mac", "ip", "daddr", "@" + as_string(localv4_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
nft_add_rule(table, "guest_forward", [ "ether", "saddr", "@tachyon_guest_mac", "ip6", "daddr", "@" + as_string(localv6_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
}
if (length(guest_ips) > 0) {
nft_add_rule(table, "guest_forward", [ "ip", "saddr", "@tachyon_guest_ip", "ip", "daddr", "@" + as_string(localv4_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
nft_add_rule(table, "guest_forward", [ "ip", "saddr", "@tachyon_guest_ip", "ip6", "daddr", "@" + as_string(localv6_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
}
if (length(guest_ip6s) > 0) {
nft_add_rule(table, "guest_forward", [ "ip6", "saddr", "@tachyon_guest_ip6", "ip6", "daddr", "@" + as_string(localv6_set), "counter", "drop", "comment", "\"tachyon-guest-lan-drop\"" ]);
}
}
// 3. Per-device accounting counter rules for quota tracking
for (let m in guest_macs) {
nft_add_rule(table, "guest_forward", [ "ether", "saddr", m, "counter", "comment", "\"tachyon-guest-byte:" + m + "\"" ]);
}
for (let ip in guest_ips) {
nft_add_rule(table, "guest_forward", [ "ip", "saddr", ip, "counter", "comment", "\"tachyon-guest-byte:" + ip + "\"" ]);
}
}
// Schedule restriction (time window / days)
let start_time = option(s, "start_time", "");
let end_time = option(s, "end_time", "");
if (start_time != "" && end_time != "" && (start_time != "00:00" || end_time != "23:59")) {
let intervals = nft_schedule_time_intervals(start_time, end_time);
let days_args = nft_schedule_days_match_args(s);
for (let interval in intervals) {
let win_rule = [ "meta", "hour", sprintf("\"%s\"-\"%s\"", interval[0], interval[1]) ];
append_array(win_rule, days_args);
append_array(win_rule, [ "return" ]);
nft_add_rule(table, "guest_forward", win_rule);
}
if (mode == "inverted") {
nft_add_rule(table, "guest_forward", [ "iifname", "@" + as_string(interface_set), "counter", "drop", "comment", "\"guest-time-window-closed\"" ]);
} else {
if (length(guest_macs) > 0)
nft_add_rule(table, "guest_forward", [ "ether", "saddr", "@tachyon_guest_mac", "counter", "drop", "comment", "\"guest-time-window-closed\"" ]);
if (length(guest_ips) > 0)
nft_add_rule(table, "guest_forward", [ "ip", "saddr", "@tachyon_guest_ip", "counter", "drop", "comment", "\"guest-time-window-closed\"" ]);
if (length(guest_ip6s) > 0)
nft_add_rule(table, "guest_forward", [ "ip6", "saddr", "@tachyon_guest_ip6", "counter", "drop", "comment", "\"guest-time-window-closed\"" ]);
}
}
return true;
}
function nft_add_guest_mode_rules_from_uci(table, interface_set, localv4_set, localv6_set) {
return nft_add_guest_mode_rules(uci_sections("guest_mode"), table, interface_set, localv4_set, localv6_set);
}
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");
@@ -1259,6 +1437,7 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
!nft_create_priority_chains(table) ||
!nft_create_chain(table, "parental_control", "{ }") ||
!nft_create_chain(table, "parental_forward", "{ }") ||
!nft_create_chain(table, "guest_forward", "{ }") ||
!nft_create_chain(table, "dns_block", "{ type nat hook prerouting priority -101; policy accept; }") ||
!nft_create_chain(table, "proxy", "{ type filter hook prerouting priority -100; policy accept; }"))
return false;
@@ -1343,6 +1522,7 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
return false;
if (!nft_create_chain(table, "mangle_forward", "{ type filter hook forward priority -150; policy accept; }") ||
!nft_add_rule(table, "mangle_forward", [ "jump", "guest_forward" ]) ||
!nft_add_rule(table, "mangle_forward", [ "jump", "parental_forward" ]))
return false;
@@ -2122,7 +2302,22 @@ function nft_profile_signature_body(body, profile) {
return body;
}
function nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles) {
function nft_guest_mode_signature_body(body, guest_mode) {
if (!guest_mode) return body;
let name = as_string(guest_mode[".name"] || "guest_mode");
body = signature_add_value(body, "guest_mode." + name + ".enabled", bool_option(guest_mode, "enabled", false) ? "1" : "0");
body = signature_add_value(body, "guest_mode." + name + ".mode", option(guest_mode, "mode", "selected"));
body = signature_add_value(body, "guest_mode." + name + ".guest_devices", join(",", list_option(guest_mode, "guest_devices")));
body = signature_add_value(body, "guest_mode." + name + ".trusted_devices", join(",", list_option(guest_mode, "trusted_devices")));
body = signature_add_value(body, "guest_mode." + name + ".isolate_lan", bool_option(guest_mode, "isolate_lan", true) ? "1" : "0");
body = signature_add_value(body, "guest_mode." + name + ".block_router_admin", bool_option(guest_mode, "block_router_admin", true) ? "1" : "0");
body = signature_add_value(body, "guest_mode." + name + ".start_time", option(guest_mode, "start_time", ""));
body = signature_add_value(body, "guest_mode." + name + ".end_time", option(guest_mode, "end_time", ""));
body = signature_add_value(body, "guest_mode." + name + ".days", join(",", list_option(guest_mode, "days")));
return body;
}
function nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles, guest_modes) {
let body = "";
body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan"));
@@ -2139,11 +2334,16 @@ function nft_runtime_signature_from_settings_and_sections(settings, sections, sc
for (let schedule in schedules)
body = nft_schedule_signature_body(body, object_or_empty(schedule));
if (guest_modes) {
for (let gm in guest_modes)
body = nft_guest_mode_signature_body(body, object_or_empty(gm));
}
return signature_hash(body);
}
function print_nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles) {
let hash = nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles);
function print_nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles, guest_modes) {
let hash = nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles, guest_modes);
if (hash == "")
return false;
@@ -2192,6 +2392,7 @@ function nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_s
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_add_dns_block_rules_from_uci(table) &&
nft_add_guest_mode_rules_from_uci(table, interface_set, localv4_set, localv6_set) &&
nft_add_profile_doh_block_rules(uci_sections("profile"), table) &&
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) &&
@@ -2220,7 +2421,8 @@ function nft_runtime_signature_from_uci() {
uci_settings(),
uci_sections("section"),
uci_sections("schedule"),
uci_sections("profile")
uci_sections("profile"),
uci_sections("guest_mode")
);
}
@@ -2241,7 +2443,8 @@ function nft_runtime_signature_from_fixture(path) {
fixture_settings(data),
fixture_section_list(data, "section"),
fixture_section_list(data, "schedule"),
fixture_section_list(data, "profile")
fixture_section_list(data, "profile"),
fixture_section_list(data, "guest_mode")
);
}
@@ -2560,6 +2763,10 @@ 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], fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "profile")) ? 0 : 1);
else if (mode == "nft-add-dns-block-rules-fixture")
exit(nft_add_dns_block_rules_from_schedules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "schedule"), ARGV[2], fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "profile")) ? 0 : 1);
else if (mode == "nft-add-guest-mode-rules-from-uci")
exit(nft_add_guest_mode_rules_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1);
else if (mode == "nft-add-guest-mode-rules-fixture")
exit(nft_add_guest_mode_rules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "guest_mode"), ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 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")
+40 -4
View File
@@ -253,6 +253,25 @@ let strings = {
cmd_restart: "Restart Tachyon services",
cmd_instances: "Live servers",
cmd_lang: "Change language",
cmd_guest: "Guest mode",
menu_guest: "👥 Guest Mode",
guest_title: "Guest Mode",
guest_enabled: "Guest mode enabled",
guest_disabled: "Guest mode disabled",
guest_status_active: "🟢 Active",
guest_status_inactive: "⚪ Inactive",
guest_mode_selected: "Selected devices",
guest_mode_inverted: "All except trusted",
guest_devices_count: "Guest devices: %d",
guest_trusted_count: "Trusted devices: %d",
guest_time_limit: "Daily time limit: %d min",
guest_traffic_limit: "Daily traffic limit: %s",
guest_lan_isolated: "LAN isolation: Active (RFC1918 & router management blocked)",
guest_time_spent: "Time spent today: %d min",
guest_traffic_spent: "Traffic used today: %s",
guest_limit_reached_time: "⏳ Guest device %s reached daily time limit (%d min). Access blocked until midnight.",
guest_limit_reached_traffic: "📊 Guest device %s reached daily traffic limit (%s). Access blocked until midnight.",
btn_guest_toggle: "👥 Guest Mode",
// ─── Misc ───
exec_done: "<b>Done (code %s):</b>\n<pre>%s</pre>",
@@ -785,6 +804,25 @@ let strings = {
cmd_restart: "Перезапуск служб Tachyon",
cmd_instances: "Live серверы",
cmd_lang: "Сменить язык",
cmd_guest: "Гостевой режим",
menu_guest: "👥 Гостевой режим",
guest_title: "Гостевой режим",
guest_enabled: "Гостевой режим включён",
guest_disabled: "Гостевой режим выключен",
guest_status_active: "🟢 Активен",
guest_status_inactive: "⚪ Отключен",
guest_mode_selected: "Выбранные устройства",
guest_mode_inverted: "Все, кроме доверенных",
guest_devices_count: "Гостевых устройств: %d",
guest_trusted_count: "Доверенных устройств: %d",
guest_time_limit: "Дневной лимит времени: %d мин",
guest_traffic_limit: "Дневной лимит трафика: %s",
guest_lan_isolated: "Изоляция LAN: Активна (доступ к локальной сети и роутеру заблокирован)",
guest_time_spent: "Времени сегодня: %d мин",
guest_traffic_spent: "Трафика сегодня: %s",
guest_limit_reached_time: "⏳ Гостевое устройство %s исчерпало дневной лимит времени (%d мин). Доступ заблокирован до полуночи.",
guest_limit_reached_traffic: "📊 Гостевое устройство %s исчерпало дневной лимит трафика (%s). Доступ заблокирован до полуночи.",
btn_guest_toggle: "👥 Гостевой режим",
// ─── Misc ───
exec_done: "<b>Выполнено (код %s):</b>\n<pre>%s</pre>",
@@ -1099,14 +1137,12 @@ function bind(lang) {
// Return available languages as [{code, label, available}]
function available_languages(cfg_lang) {
let current = resolve_lang(cfg_lang);
let ru_available = (current == "ru");
let langs = [
{ code: "en", label: "English", available: true },
{ code: "ru", label: "Русский", available: ru_available }
{ code: "ru", label: "Русский", available: true }
];
return langs;
}
// Module exports
if (sourcepath(1) != null && sourcepath(1) != "")
return { resolve_lang, bind, available_languages, strings };
return { resolve_lang, bind, available_languages, strings };
+177 -14
View File
@@ -61,8 +61,15 @@ function list_option(section, key) {
let value = object_or_empty(section)[key];
if (value == null)
return [];
if (type(value) != "array")
value = [ value ];
if (type(value) != "array") {
let result = [];
for (let item in split(replace(as_string(value), /[[:space:],]+/g, " "), " ")) {
item = trim(item);
if (item != "")
push(result, item);
}
return result;
}
let result = [];
for (let item in value) {
item = trim(as_string(item));
@@ -143,14 +150,82 @@ function quota_schedules() {
return result;
}
function str_option(section, key, fallback) {
let value = object_or_empty(section)[key];
if (value == null || value == "")
return fallback;
return as_string(value);
}
function guest_mode_config() {
let sections = uci_core.section_objects(CONFIG_NAME, "guest_mode");
if (length(sections) == 0)
return null;
let s = object_or_empty(sections[0]);
if (!bool_option(s, "enabled", false))
return null;
let time_limit = int_option(s, "daily_time_limit", 0);
let traffic_limit = int_option(s, "daily_traffic_limit", 0);
if (time_limit <= 0 && traffic_limit <= 0)
return null;
return {
mode: str_option(s, "mode", "selected"),
time_limit,
traffic_limit,
guest_devices: list_option(s, "guest_devices"),
trusted_devices: list_option(s, "trusted_devices"),
notify: bool_option(s, "notify", true)
};
}
function guest_quota_active() {
return guest_mode_config() != null;
}
function all_active_neighbors() {
let result = [];
for (let line in split(command_output_from_args([ "ip", "neigh", "show" ]), "\n")) {
line = trim(as_string(line));
if (line == "") continue;
if (match(line, /(REACHABLE|STALE|DELAY|PROBE|PERMANENT)/) != null) {
let parts = split(line, " ");
let ip = parts[0];
let mac = null;
for (let i = 0; i < length(parts) - 1; i++) {
if (parts[i] == "lladdr") {
mac = lc(parts[i + 1]);
break;
}
}
push(result, { ip, mac });
}
}
return result;
}
function read_guest_traffic_bytes() {
let output = command_output_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "guest_forward" ]) || "";
let result = {};
for (let line in split(output, "\n")) {
let m = match(line, /bytes\s+([0-9]+)\s+comment\s+"tachyon-guest-byte:([^"]+)"/);
if (m) {
let bytes = int(m[1]);
let ident = lc(m[2]);
result[ident] = bytes;
}
}
return result;
}
function read_state() {
let raw = fs.readfile(STATE_FILE);
if (raw == null || trim(as_string(raw)) == "")
return { day: "", devices: {} };
return { day: "", devices: {}, guest_devices: {} };
let parsed = object_or_empty(json(raw));
return {
day: as_string(parsed["day"] || ""),
devices: object_or_empty(parsed["devices"])
devices: object_or_empty(parsed["devices"]),
guest_devices: object_or_empty(parsed["guest_devices"])
};
}
@@ -268,10 +343,9 @@ function remove_cron() {
}
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)
// Only install the cron tick if there is at least one schedule or guest mode
// with daily limits. Without quotas the job produces nft stderr which crond logs as cron.err.
if (length(quota_schedules()) == 0 && !guest_quota_active())
return remove_cron();
let line = cron_line();
@@ -285,7 +359,7 @@ function install_cron() {
}
function blocked_lists(state) {
// MAC/IP idents currently over quota. On day rollover nothing stays
// MAC/IP idents currently over quota (parental + guest). On day rollover nothing stays
// blocked because counters start fresh.
let macs = [];
let ips = [];
@@ -298,23 +372,35 @@ function blocked_lists(state) {
else
push(ips, ident);
}
for (let ident, entry in state.guest_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.
let gm_cfg = guest_mode_config();
if (length(schedules) == 0 && gm_cfg == null) {
// No quota schedules configured and no guest quotas active
return 0;
}
let today = today_str();
let state = read_state();
if (as_string(state.day) != today)
state = { day: today, devices: {} };
state = { day: today, devices: {}, guest_devices: {} };
let devices = object_or_empty(state.devices);
let guest_devices = object_or_empty(state.guest_devices);
// 1. Parental Control schedules
for (let schedule in schedules) {
for (let device in schedule.devices) {
let ident = device.ident;
@@ -335,7 +421,66 @@ function tick() {
}
}
// 2. Guest Mode quotas
if (gm_cfg != null) {
let target_guests = [];
if (gm_cfg.mode == "inverted") {
let trusted_set = {};
for (let dev in gm_cfg.trusted_devices)
trusted_set[lc(trim(as_string(dev)))] = true;
let neighbors = all_active_neighbors();
for (let neigh in neighbors) {
let is_trusted = trusted_set[neigh.ip] || (neigh.mac != null && trusted_set[neigh.mac]);
if (!is_trusted) {
let ident = neigh.mac || neigh.ip;
push(target_guests, { ident, is_mac: neigh.mac != null, active: true });
}
}
} else {
for (let dev in gm_cfg.guest_devices) {
dev = trim(as_string(dev));
if (dev == "") continue;
let ident = lc(dev);
let is_mac = match(ident, MAC_RE) != null;
push(target_guests, { ident, is_mac, active: device_active(ident) });
}
}
let traffic_bytes_map = read_guest_traffic_bytes();
for (let guest in target_guests) {
let ident = guest.ident;
let entry = object_or_empty(guest_devices[ident]);
let minutes = int(entry.minutes || 0);
let bytes = int(traffic_bytes_map[ident] || entry.bytes || 0);
let was_blocked = entry.blocked == true;
if (!was_blocked) {
if (guest.active)
minutes++;
let time_exceeded = gm_cfg.time_limit > 0 && minutes >= gm_cfg.time_limit;
let traffic_mb = int(bytes / (1024 * 1024));
let traffic_exceeded = gm_cfg.traffic_limit > 0 && traffic_mb >= gm_cfg.traffic_limit;
if (time_exceeded || traffic_exceeded) {
let reason = time_exceeded ? (as_string(gm_cfg.time_limit) + " мин") : (as_string(gm_cfg.traffic_limit) + " МБ");
guest_devices[ident] = { minutes, bytes, blocked: true, reason };
log_message("guest device " + ident + " hit quota (" + reason + "); blocking until midnight", "info");
if (gm_cfg.notify)
send_notification("🚫 *Guest Mode*: устройство `" + ident + "` исчерпало дневную квоту (" + reason + "), доступ заблокирован до полуночи.");
} else {
guest_devices[ident] = { minutes, bytes, blocked: false };
}
} else {
guest_devices[ident] = { minutes, bytes, blocked: true, reason: entry.reason };
}
}
}
state.devices = devices;
state.guest_devices = guest_devices;
write_state(state);
let lists = blocked_lists(state);
@@ -345,15 +490,33 @@ function tick() {
function status_json() {
let schedules = quota_schedules();
let gm_cfg = guest_mode_config();
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 };
}
let guest_tracked = {};
for (let ident, entry in object_or_empty(state.guest_devices)) {
entry = object_or_empty(entry);
guest_tracked[ident] = {
minutes: int(entry.minutes || 0),
bytes: int(entry.bytes || 0),
blocked: entry.blocked == true,
reason: as_string(entry.reason || "")
};
}
write_json({
configured: length(schedules) > 0,
configured: length(schedules) > 0 || gm_cfg != null,
schedules: length(schedules),
guest_mode: gm_cfg != null ? {
enabled: true,
mode: gm_cfg.mode,
time_limit: gm_cfg.time_limit,
traffic_limit: gm_cfg.traffic_limit,
devices: guest_tracked
} : { enabled: false },
day: as_string(state.day || ""),
devices: tracked
});
+126 -29
View File
@@ -16,6 +16,9 @@ const OFFSET_FILE = "/var/run/tachyon_telegram_offset";
let as_string = common.as_string;
let option = common.option;
let bool_option = common.bool_option;
let int_option = common.int_option;
let list_option = common.list_option;
let shell_quote = common.shell_quote;
let object_or_empty = common.object_or_empty;
let command_status = common.command_status;
@@ -688,6 +691,8 @@ function view_settings_menu(token, chat_id, msg_id) {
[{ text: t("menu_servers_short"), callback_data: "/set_list server" }],
[{ text: t("menu_dns_presets"), callback_data: "/dns_presets" }],
[{ text: t("menu_quiet_hours"), callback_data: "/qh" }],
[{ text: t("menu_guest"), callback_data: "/guest" }],
[{ text: t("menu_language"), callback_data: "/lang" }],
[{ text: t("menu_test_rule"), callback_data: "/test_rule" }],
[{ text: t("menu_export_config"), callback_data: "/export_config" }],
[{ text: t("nav_back"), callback_data: "/menu" }]
@@ -1990,15 +1995,43 @@ function view_help(token, chat_id, msg_id) {
else send_message(token, chat_id, text, "HTML", keyboard);
}
// ─── Phase 4: Language, Quiet Hours, Rule Test, Export ───────────────────────
// ─── Phase 4: Language, Quiet Hours, Rule Test, Export, Guest Mode ─────────
function register_bot_commands(token) {
if (!token) return;
let commands = [
{ command: "menu", description: t("cmd_menu") },
{ command: "status", description: t("cmd_status") },
{ command: "runtime", description: t("cmd_runtime") },
{ command: "outbounds", description: t("cmd_outbounds") },
{ command: "sections", description: t("cmd_sections") },
{ command: "instances", description: t("cmd_instances") },
{ command: "guest", description: t("cmd_guest") },
{ command: "speed", description: t("cmd_speed") },
{ command: "ping", description: t("cmd_ping") },
{ command: "test", description: t("cmd_test") },
{ command: "logs", description: t("cmd_logs") },
{ command: "info", description: t("cmd_info") },
{ command: "connections", description: t("cmd_connections") },
{ command: "test_rule", description: t("cmd_test_rule") },
{ command: "help", description: t("cmd_help") },
{ command: "check_updates", description: t("cmd_check_updates") },
{ command: "close_connections", description: t("cmd_close_connections") },
{ command: "doctor", description: t("cmd_doctor") },
{ command: "restart", description: t("cmd_restart") },
{ command: "lang", description: t("cmd_lang") }
];
tg_request(token, "setMyCommands", { commands: commands });
}
function view_language(token, chat_id, msg_id) {
let c = uci_core.cursor();
c.load(CONFIG_NAME);
let current_lang = option(c.get_all(CONFIG_NAME, "telegram"), "language", "en");
let langs = i18n.available_languages(current_lang);
let lang_label = (current_lang == "ru") ? "Русский" : "English";
let text = t("choose_language") + "\n\n" + t("lang_current", current_lang);
let text = t("choose_language") + "\n\n" + t("lang_current", lang_label);
let keyboard = [];
let row = [];
for (let i = 0; i < length(langs); i++) {
@@ -2020,8 +2053,89 @@ function handle_lang_set(token, chat_id, msg_id, lang) {
c.load(CONFIG_NAME);
c.set(CONFIG_NAME, "telegram", "language", lang);
c.commit(CONFIG_NAME);
return send_message(token, chat_id, t("lang_saved") + " " + t("lang_current", lang), "HTML",
[[{ text: t("nav_back"), callback_data: "/settings" }]]);
t = i18n.bind(lang);
register_bot_commands(token);
let lang_label = (lang == "ru") ? "Русский" : "English";
let text = "✅ <b>" + t("lang_saved") + "</b>\n\n" + t("lang_current", lang_label);
let keyboard = [[{ text: t("nav_back"), callback_data: "/settings" }]];
if (msg_id) return edit_message(token, chat_id, msg_id, text, "HTML", keyboard);
return send_message(token, chat_id, text, "HTML", keyboard);
}
function view_guest_mode(token, chat_id, msg_id) {
let c = uci_core.cursor();
c.load(CONFIG_NAME);
let s = object_or_empty(c.get_all(CONFIG_NAME, "guest_mode"));
let enabled = bool_option(s, "enabled", false);
let gmode = option(s, "mode", "selected");
let mode_label = (gmode == "inverted") ? t("guest_mode_inverted") : t("guest_mode_selected");
let guest_devs = list_option(s, "guest_devices");
let trusted_devs = list_option(s, "trusted_devices");
let time_limit = int_option(s, "daily_time_limit", 0);
let traffic_limit = int_option(s, "daily_traffic_limit", 0);
let isolate_lan = bool_option(s, "isolate_lan", true);
let text = "👥 <b>" + t("guest_title") + "</b>\n\n" +
(enabled ? t("guest_status_active") : t("guest_status_inactive")) + "\n" +
"📋 " + t("routing_mode") + ": <b>" + mode_label + "</b>\n";
if (gmode == "inverted") {
text += sprintf(t("guest_trusted_count"), length(trusted_devs)) + "\n";
} else {
text += sprintf(t("guest_devices_count"), length(guest_devs)) + "\n";
}
if (isolate_lan) {
text += "🛡 " + t("guest_lan_isolated") + "\n";
}
if (time_limit > 0) {
text += sprintf(t("guest_time_limit"), time_limit) + "\n";
}
if (traffic_limit > 0) {
text += sprintf(t("guest_traffic_limit"), as_string(traffic_limit) + " MB") + "\n";
}
let quota_state = fs.readfile("/var/run/tachyon/parental_quotas.json");
if (quota_state) {
try {
let q = json(quota_state);
let devs = object_or_empty(q.devices);
let active_guests = [];
for (let ident in keys(devs)) {
let dev_entry = devs[ident];
if (dev_entry.is_guest || dev_entry.blocked) {
let st = dev_entry.blocked ? "🚫" : "⏳";
let info = st + " <code>" + ident + "</code>: " + as_string(dev_entry.minutes || 0) + " мин";
if (dev_entry.bytes) info += " (" + format_bytes(dev_entry.bytes) + ")";
push(active_guests, info);
}
}
if (length(active_guests) > 0) {
text += "\n<b>Активность сегодня:</b>\n" + join("\n", active_guests) + "\n";
}
} catch(e) {}
}
let toggle_label = enabled ? ("🔴 " + t("btn_disable")) : ("🟢 " + t("btn_enable"));
let keyboard = [
[{ text: toggle_label, callback_data: "/guest_toggle" }],
[{ text: t("nav_back"), callback_data: "/settings" }]
];
if (msg_id) edit_message(token, chat_id, msg_id, text, "HTML", keyboard);
else send_message(token, chat_id, text, "HTML", keyboard);
}
function handle_guest_toggle(token, chat_id, msg_id) {
let c = uci_core.cursor();
c.load(CONFIG_NAME);
let s = object_or_empty(c.get_all(CONFIG_NAME, "guest_mode"));
let cur = bool_option(s, "enabled", false);
let nxt = cur ? "0" : "1";
c.set(CONFIG_NAME, "guest_mode", "enabled", nxt);
c.commit(CONFIG_NAME);
command_status(command_from_args(["/usr/bin/tachyon", "reload_firewall"]));
return view_guest_mode(token, chat_id, msg_id);
}
function view_quiet_hours(token, chat_id, msg_id) {
@@ -2376,11 +2490,14 @@ function dispatch_command(token, chat_id, text, msg_id) {
if (cmd == "/test") return view_quick_test(token, chat_id, msg_id);
if (cmd == "/info") return view_system_info(token, chat_id, msg_id);
if (cmd == "/help") return view_help(token, chat_id, msg_id);
if (cmd == "/lang") return view_language(token, chat_id, msg_id);
if (match(cmd, /^\/lang_set /)) {
let lang = trim(substr(cmd, 11));
if (cmd == "/lang" || cmd == "/language") return view_language(token, chat_id, msg_id);
let lang_match = match(cmd, /^\/(?:lang_set|lang|language)[ \t]+([a-zA-Z0-9_-]+)/);
if (lang_match) {
let lang = lc(trim(lang_match[1]));
return handle_lang_set(token, chat_id, msg_id, lang);
}
if (cmd == "/guest") return view_guest_mode(token, chat_id, msg_id);
if (cmd == "/guest_toggle") return handle_guest_toggle(token, chat_id, msg_id);
if (match(cmd, /^\/logs /)) {
let parts = split(trim(substr(cmd, 6)), " ");
@@ -3112,28 +3229,8 @@ function worker() {
let cfg = settings();
if (cfg.enabled != "1" || !cfg.bot_token) return 0;
let commands = [
{ command: "menu", description: t("cmd_menu") },
{ command: "status", description: t("cmd_status") },
{ command: "runtime", description: t("cmd_runtime") },
{ command: "outbounds", description: t("cmd_outbounds") },
{ command: "sections", description: t("cmd_sections") },
{ command: "instances", description: t("cmd_instances") },
{ command: "speed", description: t("cmd_speed") },
{ command: "ping", description: t("cmd_ping") },
{ command: "test", description: t("cmd_test") },
{ command: "logs", description: t("cmd_logs") },
{ command: "info", description: t("cmd_info") },
{ command: "connections", description: t("cmd_connections") },
{ command: "test_rule", description: t("cmd_test_rule") },
{ command: "help", description: t("cmd_help") },
{ command: "check_updates", description: t("cmd_check_updates") },
{ command: "close_connections", description: t("cmd_close_connections") },
{ command: "doctor", description: t("cmd_doctor") },
{ command: "restart", description: t("cmd_restart") },
{ command: "lang", description: t("cmd_lang") }
];
tg_request(cfg.bot_token, "setMyCommands", { commands: commands });
t = i18n.bind(cfg.language);
register_bot_commands(cfg.bot_token);
// Clean up leftover payload temp-files from previous runs that were
// interrupted (e.g. killed during a 20-second getUpdates long-poll).
+266
View File
@@ -0,0 +1,266 @@
#!/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"
PARENTAL_QUOTA="$ROOT_DIR/tachyon/files/usr/lib/service/parental_quota.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'"
}
assert_not_contains() {
local file="$1"
local unexpected="$2"
local label="$3"
if grep -Fq "$unexpected" "$file"; then
fail "$label: unexpected '$unexpected' found"
fi
}
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"
# ─── 1. Selected Mode Test ───────────────────────────────────────────────────
cat >"$WORK_DIR/guest_selected.json" <<'JSON'
{
"guest_mode": [
{
".name": "guest_mode",
"enabled": "1",
"mode": "selected",
"guest_devices": [ "192.168.1.150", "aa:bb:cc:dd:ee:ff" ],
"isolate_lan": "1",
"block_router_admin": "1",
"start_time": "08:00",
"end_time": "22:00",
"days": [ "mon", "tue", "wed", "thu", "fri" ]
}
]
}
JSON
rm -f "$NFT_LOG"
nft_ucode nft-add-guest-mode-rules-fixture "$WORK_DIR/guest_selected.json" "tachyon" "tachyon_ifaces" "localv4" "localv6"
# Router admin drop in guest_input
assert_contains "$NFT_LOG" "guest_input ether saddr @tachyon_guest_mac counter drop comment \"tachyon-guest-admin-drop\"" "selected mac admin drop"
assert_contains "$NFT_LOG" "guest_input ip saddr @tachyon_guest_ip counter drop comment \"tachyon-guest-admin-drop\"" "selected ip admin drop"
# Essential services allowed in guest_input
assert_contains "$NFT_LOG" "guest_input udp dport { 67, 68 } accept" "DHCP accepted"
assert_contains "$NFT_LOG" "guest_input udp dport 53 accept" "DNS UDP accepted"
assert_contains "$NFT_LOG" "guest_input tcp dport 53 accept" "DNS TCP accepted"
assert_contains "$NFT_LOG" "guest_input icmp type echo-request accept" "ICMP echo accepted"
# LAN isolation in guest_forward
assert_contains "$NFT_LOG" "guest_forward ether saddr @tachyon_guest_mac ip daddr @localv4 counter drop comment \"tachyon-guest-lan-drop\"" "mac lan drop"
assert_contains "$NFT_LOG" "guest_forward ip saddr @tachyon_guest_ip ip daddr @localv4 counter drop comment \"tachyon-guest-lan-drop\"" "ip lan drop"
# Byte accounting counters
assert_contains "$NFT_LOG" "guest_forward ether saddr aa:bb:cc:dd:ee:ff counter comment \"tachyon-guest-byte:aa:bb:cc:dd:ee:ff\"" "mac byte counter"
assert_contains "$NFT_LOG" "guest_forward ip saddr 192.168.1.150 counter comment \"tachyon-guest-byte:192.168.1.150\"" "ip byte counter"
# Schedule restriction drops only guest devices outside window
assert_contains "$NFT_LOG" "guest_forward ether saddr @tachyon_guest_mac counter drop comment \"guest-time-window-closed\"" "mac time window drop"
assert_contains "$NFT_LOG" "guest_forward ip saddr @tachyon_guest_ip counter drop comment \"guest-time-window-closed\"" "ip time window drop"
# ─── 2. Inverted Mode Test ───────────────────────────────────────────────────
cat >"$WORK_DIR/guest_inverted.json" <<'JSON'
{
"guest_mode": [
{
".name": "guest_mode",
"enabled": "1",
"mode": "inverted",
"trusted_devices": [ "192.168.1.10", "11:22:33:44:55:66" ],
"isolate_lan": "1",
"block_router_admin": "1",
"start_time": "09:00",
"end_time": "21:00"
}
]
}
JSON
rm -f "$NFT_LOG"
nft_ucode nft-add-guest-mode-rules-fixture "$WORK_DIR/guest_inverted.json" "tachyon" "tachyon_ifaces" "localv4" "localv6"
# Trusted devices return early
assert_contains "$NFT_LOG" "guest_input ether saddr @tachyon_trusted_mac return" "trusted mac returns in input"
assert_contains "$NFT_LOG" "guest_input ip saddr @tachyon_trusted_ip return" "trusted ip returns in input"
assert_contains "$NFT_LOG" "guest_forward ether saddr @tachyon_trusted_mac return" "trusted mac returns in forward"
assert_contains "$NFT_LOG" "guest_forward ip saddr @tachyon_trusted_ip return" "trusted ip returns in forward"
# All non-trusted devices dropped
assert_contains "$NFT_LOG" "guest_input iifname @tachyon_ifaces counter drop comment \"tachyon-guest-admin-drop\"" "inverted admin drop"
assert_contains "$NFT_LOG" "guest_forward iifname @tachyon_ifaces ip daddr @localv4 counter drop comment \"tachyon-guest-lan-drop\"" "inverted lan drop"
assert_contains "$NFT_LOG" "guest_forward iifname @tachyon_ifaces counter comment \"tachyon-guest-traffic\"" "inverted traffic counter"
assert_contains "$NFT_LOG" "guest_forward iifname @tachyon_ifaces counter drop comment \"guest-time-window-closed\"" "inverted time window drop"
# ─── 3. Validator Tests ──────────────────────────────────────────────────────
cat >"$WORK_DIR/valid_config.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"guest_mode": [
{
".name": "guest_mode",
"enabled": "1",
"mode": "selected",
"guest_devices": [ "192.168.1.50", "aa:bb:cc:dd:ee:ff" ],
"daily_time_limit": "120",
"daily_traffic_limit": "1024",
"start_time": "08:00",
"end_time": "20:00"
}
]
}
JSON
if ! ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/valid_config.json" "{}" >/dev/null 2>&1; then
fail "Validator should accept valid guest_mode config"
fi
cat >"$WORK_DIR/invalid_mode.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"guest_mode": [
{
".name": "guest_mode",
"enabled": "1",
"mode": "invalid_mode_name"
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_mode.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject invalid guest mode"
fi
cat >"$WORK_DIR/invalid_device.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"guest_mode": [
{
".name": "guest_mode",
"enabled": "1",
"mode": "selected",
"guest_devices": [ "not-an-ip-or-mac" ]
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_device.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject invalid guest device format"
fi
cat >"$WORK_DIR/invalid_quota.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"guest_mode": [
{
".name": "guest_mode",
"enabled": "1",
"daily_time_limit": "99999"
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_quota.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject out-of-range daily_time_limit"
fi
# ─── 4. Parental Quota Daemon Guest Integration ──────────────────────────────
STATE_FILE="$WORK_DIR/quotas.json"
UCI_STATE="$WORK_DIR/uci_state"
cat >"$UCI_STATE" <<'EOF'
tachyon.guest_mode=guest_mode
tachyon.guest_mode.enabled=1
tachyon.guest_mode.mode=selected
tachyon.guest_mode.guest_devices=192.168.1.88 aa:bb:cc:dd:ee:ff
tachyon.guest_mode.daily_time_limit=60
tachyon.guest_mode.daily_traffic_limit=500
tachyon.guest_mode.notify=0
EOF
# Prepopulate state file with 59 minutes (1 minute below limit)
cat >"$STATE_FILE" <<'EOF'
{"day":"2026-09-08","devices":{},"guest_devices":{"192.168.1.88":{"minutes":59,"bytes":0,"blocked":false}}}
EOF
# Mock `date`, `ip`, `logger`
cat >"$WORK_DIR/bin/date" <<'EOF'
#!/usr/bin/env bash
echo "2026-09-08"
EOF
chmod +x "$WORK_DIR/bin/date"
cat >"$WORK_DIR/bin/logger" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$WORK_DIR/bin/logger"
cat >"$WORK_DIR/bin/ip" <<'EOF'
#!/usr/bin/env bash
if [ "$1" = "neigh" ] && [ "$2" = "show" ]; then
echo "192.168.1.88 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE"
fi
exit 0
EOF
chmod +x "$WORK_DIR/bin/ip"
# Run tick with TACHYON_UCI_STATE_FILE
PARENTAL_QUOTA_STATE_FILE="$STATE_FILE" TACHYON_UCI_STATE_FILE="$UCI_STATE" ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" tick
# Device should now have reached 60 minutes and be marked blocked
grep -Eq '"blocked":\s*true' "$STATE_FILE" || fail "Guest device should be blocked after reaching time limit"
# Test status command
STATUS_JSON="$(PARENTAL_QUOTA_STATE_FILE="$STATE_FILE" TACHYON_UCI_STATE_FILE="$UCI_STATE" ucode -L "$TACHYON_LIB" "$PARENTAL_QUOTA" status)"
echo "$STATUS_JSON" | grep -q '"guest_mode"' || fail "Status must contain guest_mode object"
echo "$STATUS_JSON" | grep -Eq '"time_limit":\s*60' || fail "Status must contain time_limit:60"
echo "$STATUS_JSON" | grep -Eq '"traffic_limit":\s*500' || fail "Status must contain traffic_limit:500"
printf 'All guest mode isolation, validation, and quota tests passed successfully\n'
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
TG="$TACHYON_LIB/service/telegram.uc"
I18N="$TACHYON_LIB/service/i18n.uc"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
[ -f "$TG" ] || fail "telegram.uc not found"
[ -f "$I18N" ] || fail "i18n.uc not found"
# 1. Available languages: Russian must be unconditionally available (not gated by current==ru)
grep -Fq '{ code: "ru", label: "Русский", available: true }' "$I18N" ||
fail "i18n.uc must make Russian unconditionally available"
# 2. telegram.uc must not have the broken substr(cmd, 11) for lang_set that chopped 'ru' to 'u'
if grep -E 'lang.*substr\(cmd, 11\)' "$TG"; then
fail "telegram.uc must NOT use substr(cmd, 11) for language commands"
fi
# 3. /lang_set matching must capture the full language code
grep -Fq 'lang_match = match(cmd, /^\/(?:lang_set|lang|language)' "$TG" ||
fail "telegram.uc must robustly match /lang_set and /lang commands with arguments"
# 4. handle_lang_set must rebind t immediately
grep -Fq 't = i18n.bind(lang);' "$TG" ||
fail "handle_lang_set must rebind translation function 't' immediately"
# 5. handle_lang_set must update Telegram bot commands menu
grep -Fq 'register_bot_commands(token);' "$TG" ||
fail "handle_lang_set must call register_bot_commands to update bot menu"
# 6. /guest and /guest_toggle routes must be defined in telegram.uc
grep -Fq 'function view_guest_mode(token, chat_id, msg_id)' "$TG" ||
fail "telegram.uc must define view_guest_mode"
grep -Fq 'function handle_guest_toggle(token, chat_id, msg_id)' "$TG" ||
fail "telegram.uc must define handle_guest_toggle"
grep -Fq 'if (cmd == "/guest")' "$TG" ||
fail "dispatch_command must route /guest"
grep -Fq 'if (cmd == "/guest_toggle")' "$TG" ||
fail "dispatch_command must route /guest_toggle"
# 7. Run ucode evaluation test of i18n language resolution and strings
ucode -e '
let i18n = loadfile("'"$I18N"'")();
if (i18n.resolve_lang("ru") != "ru") exit(1);
if (i18n.resolve_lang("en") != "en") exit(2);
if (i18n.resolve_lang("invalid") != "en") exit(3);
let t_ru = i18n.bind("ru");
if (t_ru("lang_saved") != "Язык сохранён!") exit(4);
if (t_ru("cmd_guest") != "Гостевой режим") exit(5);
let t_en = i18n.bind("en");
if (t_en("lang_saved") != "Language saved!") exit(6);
if (t_en("cmd_guest") != "Guest mode") exit(7);
let langs = i18n.available_languages("en");
if (length(langs) != 2 || !langs[0].available || !langs[1].available) exit(8);
' || fail "ucode i18n evaluation failed"
printf 'telegram language and guest route checks passed\n'