feat: add Guest Mode isolation with quotas and fix Telegram bot language switching
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user