Files
NeoTachyon/tachyon/files/usr/lib/config/validator.uc
T
Dushnilin 2019ddd12a release: 1.3.26 — Watchdog proxy restart protection, WAN monitor debounce, and subscription bootstrap resilience
- Fix #52: Watchdog proxy restart on single URL failure:
  * Validate failure against secondary independent probe URL before declaring proxy down
  * Skip restart when direct WAN is also degraded or down (!direct_ok)
  * Implement 120s cooldown between automated proxy restarts
  * Fix Telegram notification to accurately state 'Перезапуск sing-box' instead of 'Перезапуск роутера'
- Fix #51: Telegram bot spamming WAN up/down notifications:
  * Filter hotplug events strictly by badwan_monitored_interfaces (defaults to wan)
  * Add atomic directory lock with debounce delay and real ubus state query
  * Prevent repeated notifications when interface state has not transitioned
- Feat: 2-VPN deadlock elimination & self-download subscription proxy:
  * Isolate unbootstrapped dependent subscription sections on initial boot (deferred_sections)
  * Allow self-download proxy target for subscriptions with explicit UI hint '(текущее правило при активности)'
  * Enable automatic local proxy fallback for subscription refresh when direct WAN download fails while sing-box is active
  * Add regression test in tests/subscription_bootstrap_deadlock.sh
- Fix: Parental control quota enforcement:
  * Correct route rule filtering when daily quota minutes are exhausted without a fixed time interval
- i18n & Version bump:
  * Bump fe-app-tachyon to 1.3.26 and update Russian PO/POT translations
2026-09-12 16:50:04 +03:00

2621 lines
105 KiB
Ucode

#!/usr/bin/env ucode
let fs = require("fs");
let helpers = require("core.helpers");
let uci_core = require("core.uci");
let fixture_uci_data = null;
let subscription_parser_module = null;
let zapret_validator_module = null;
let zapret2_validator_module = null;
let byedpi_validator_module = null;
let wdtt_validator_module = null;
let olcrtc_validator_module = null;
let constants_module = null;
let core_url = require("core.url");
let core_ip = require("core.ip");
let rule_config = require("config.rule");
let connections = require("config.connections");
let common = require("core.common");
let as_string = common.as_string;
let shell_quote = common.shell_quote;
let command_from_args = common.command_from_args;
let command_output = common.command_output;
let command_output_from_args = common.command_output_from_args;
let read_json_file = common.read_json_file;
let bool_value = common.bool_value;
let bool_flag = bool_value;
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
const DEFAULT_LATENCY_TEST_URL = "https://www.gstatic.com/generate_204";
const TAILSCALE_FWMARK_MASK = 0x00ff0000;
function read_stdin() {
let input = fs.open("/dev/stdin", "r");
if (!input)
return "";
let data = input.read("all");
input.close();
return data == null ? "" : data;
}
function read_stdin_json() {
let data = read_stdin();
try {
return json(data);
}
catch (e) {
return null;
}
}
function string_starts_with(value, prefix) {
value = as_string(value);
prefix = as_string(prefix);
return substr(value, 0, length(prefix)) == prefix;
}
function string_ends_with(value, suffix) {
value = as_string(value);
suffix = as_string(suffix);
return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix;
}
function whitespace_values(value) {
let result = [];
for (let item in split(trim(as_string(value)), /[ \t\r\n]+/))
if (item != "")
push(result, item);
return result;
}
function option_list_values(section, key) {
let value = type(section) == "object" ? section[key] : null;
if (type(value) == "array")
return value;
return whitespace_values(value);
}
function list_has_value(values, needle) {
needle = as_string(needle);
if (needle == "")
return false;
for (let value in whitespace_values(values))
if (value == needle)
return true;
return false;
}
function object_or_empty(value) {
return type(value) == "object" ? value : {};
}
function array_or_empty(value) {
return type(value) == "array" ? value : [];
}
function contains(values, needle) {
for (let value in array_or_empty(values))
if (value == needle)
return true;
return false;
}
function option(section, key, fallback) {
if (fallback == null)
fallback = "";
let value = object_or_empty(section)[key];
if (value == null)
return fallback;
if (type(value) == "array")
return join(" ", value);
return as_string(value);
}
function list_option(section, key) {
let value = object_or_empty(section)[key];
if (value == null)
return [];
if (type(value) == "array")
return value;
value = trim(as_string(value));
return value == "" ? [] : split(value, " ");
}
function bool_option(section, key, fallback) {
if (fallback == null)
fallback = false;
let value = option(section, key, fallback ? "1" : "0");
return value == "1" || value == "true" || value == "yes" || value == "on";
}
function file_executable(path) {
let stat = fs.stat(as_string(path));
if (stat == null || stat.mode == null)
return false;
return (int(stat.mode) & 73) != 0;
}
function file_nonempty(path) {
return helpers.file_is_usable(path, 0);
}
function run_args(args) {
return system(command_from_args(args)) == 0;
}
function command_success_from_args(args) {
return system(command_from_args(args) + " >/dev/null 2>&1") == 0;
}
function command_exists(name) {
return system("command -v " + shell_quote(name) + " >/dev/null 2>&1") == 0;
}
function log_message(message, level) {
level = as_string(level || "info");
run_args([ "logger", "-t", "tachyon", "[" + level + "] " + as_string(message) ]);
}
function fail_requirement(message, level) {
log_message(message, level || "error");
exit(1);
}
function mkdir_p(paths) {
let args = [ "mkdir", "-p" ];
for (let path in array_or_empty(paths))
if (as_string(path) != "")
push(args, path);
return length(args) <= 2 || run_args(args);
}
function safe_rm_rf(path) {
path = as_string(path);
if (path == "" || substr(path, 0, length("/var/run/tachyon/")) != "/var/run/tachyon/")
return true;
if (index(path, "..") >= 0)
return true;
return run_args([ "rm", "-rf", path ]);
}
function first_line_field_from_text(data, field_index) {
let newline = index(as_string(data), "\n");
let line = newline >= 0 ? substr(data, 0, newline) : data;
let fields = split(trim(as_string(line)), /[ \t\r\n]+/);
field_index = int(field_index || 0);
return field_index > 0 && field_index <= length(fields) ? as_string(fields[field_index - 1]) : "";
}
function first_line_last_field(data) {
let newline = index(as_string(data), "\n");
let line = newline >= 0 ? substr(data, 0, newline) : data;
let fields = split(trim(as_string(line)), /[ \t\r\n]+/);
return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : "";
}
function digit_char(value) {
return match(as_string(value), /^[0-9]$/) != null;
}
function strip_leading_zeroes(value) {
value = as_string(value);
let i = 0;
while (i < length(value) - 1 && substr(value, i, 1) == "0")
i++;
return substr(value, i);
}
function version_compare(lhs, rhs) {
lhs = as_string(lhs);
rhs = as_string(rhs);
let li = 0, ri = 0;
while (li < length(lhs) || ri < length(rhs)) {
if (li >= length(lhs))
return substr(rhs, ri, 1) == "~" ? 1 : -1;
if (ri >= length(rhs))
return substr(lhs, li, 1) == "~" ? -1 : 1;
let lc = substr(lhs, li, 1);
let rc = substr(rhs, ri, 1);
if (lc == rc) {
li++;
ri++;
continue;
}
if (lc == "~" || rc == "~")
return lc == "~" ? -1 : 1;
if (digit_char(lc) && digit_char(rc)) {
let ls = li, rs = ri;
while (li < length(lhs) && digit_char(substr(lhs, li, 1)))
li++;
while (ri < length(rhs) && digit_char(substr(rhs, ri, 1)))
ri++;
let lnum = strip_leading_zeroes(substr(lhs, ls, li - ls));
let rnum = strip_leading_zeroes(substr(rhs, rs, ri - rs));
if (length(lnum) != length(rnum))
return length(lnum) < length(rnum) ? -1 : 1;
if (lnum != rnum)
return lnum < rnum ? -1 : 1;
continue;
}
return lc < rc ? -1 : 1;
}
return 0;
}
function version_at_least(current, required) {
return version_compare(current, required) >= 0;
}
function constant_value(constants, name) {
return as_string(object_or_empty(constants)[name]);
}
function uci_show_list_value(value) {
return trim(replace(as_string(value), /['"]/g, ""));
}
function stdin_first_line_field(field_index) {
let data = read_stdin();
let newline = index(data, "\n");
let line = newline >= 0 ? substr(data, 0, newline) : data;
let fields = split(trim(as_string(line)), /[ \t\r\n]+/);
field_index = int(field_index || 0);
if (field_index > 0 && field_index <= length(fields))
print(fields[field_index - 1], "\n");
}
function country_code_valid(value) {
value = uc(as_string(value));
return match(value, /^[A-Z][A-Z]$/) != null;
}
function enum_valid(value, start_index) {
value = as_string(value);
for (let i = start_index; i < length(ARGV); i++)
if (value == as_string(ARGV[i]))
return true;
return false;
}
function regex_valid(pattern) {
pattern = as_string(pattern);
if (pattern == "")
return true;
try {
regexp(pattern);
return true;
}
catch (e) {
return false;
}
}
function combined_domain_valid(value) {
value = trim(as_string(value));
if (value == "")
return true;
let normalized = rule_config.prefixed_domain_kind_value(value);
if (normalized == null)
return false;
return normalized.kind != "domain_regex" || regex_valid(normalized.value);
}
function combined_domain_text_valid(value) {
for (let item in rule_config.text_list_values(value, "comma-space"))
if (!combined_domain_valid(item))
return false;
return true;
}
function community_service_valid(value, services) {
return list_has_value(services, value);
}
function remote_reference(value) {
return string_starts_with(value, "http://") || string_starts_with(value, "https://");
}
function absolute_reference_with_extension(value, first_extension, second_extension) {
value = as_string(value);
if (!string_starts_with(value, "/"))
return false;
return string_ends_with(value, first_extension) ||
(second_extension != null && string_ends_with(value, second_extension));
}
function ruleset_reference_valid(reference, community_services) {
reference = as_string(reference);
return reference == "" ||
community_service_valid(reference, community_services) ||
remote_reference(reference) ||
absolute_reference_with_extension(reference, ".srs", ".json");
}
function plain_domain_ip_list_reference_valid(reference) {
reference = as_string(reference);
return reference == "" ||
remote_reference(reference) ||
absolute_reference_with_extension(reference, ".lst", null);
}
function valid_outbound() {
let value = read_stdin_json();
return type(value) == "object" && type(value.type) == "string";
}
function valid_inbound() {
let value = read_stdin_json();
return type(value) == "object" && type(value.type) == "string";
}
function outbound_detour_source_action(action) {
action = as_string(action);
return connections.is_connections_action(action) || action == "awg";
}
function outbound_detour_target_action(action) {
action = as_string(action);
return connections.is_connections_action(action) || action == "awg";
}
function outbound_detour_rows() {
let rows = [];
for (let line in split(read_stdin(), "\n")) {
line = replace(as_string(line), /\r/g, "");
if (trim(line) == "")
continue;
let fields = split(line, "\t");
let row = {
section: as_string(fields[0]),
enabled: bool_flag(fields[1]),
action: as_string(fields[2]),
detour_enabled: bool_flag(fields[3]),
detour_section: as_string(fields[4])
};
push(rows, row);
}
return rows;
}
function outbound_detour_rows_by_section(rows) {
let by_section = {};
for (let row in array_or_empty(rows))
if (row.section != "")
by_section[row.section] = row;
return {
rows: rows,
by_section: by_section
};
}
function outbound_detour_chain_reaches_source(by_section, source_section, current_section) {
let seen = {};
while (current_section != "") {
if (current_section == source_section)
return true;
if (seen[current_section])
return false;
seen[current_section] = true;
let row = by_section[current_section];
if (type(row) != "object" || !row.enabled || !outbound_detour_source_action(row.action))
return false;
if (!row.detour_enabled)
return false;
current_section = row.detour_section;
}
return false;
}
function fail_outbound_detour(message) {
print(message, "\n");
exit(1);
}
function fail_validation(message) {
print(message, "\n");
exit(1);
}
function validate_outbound_detours_rows(rows) {
let parsed = outbound_detour_rows_by_section(rows);
for (let row in parsed.rows) {
if (!row.enabled || !row.detour_enabled)
continue;
if (!outbound_detour_source_action(row.action))
fail_outbound_detour("Outbound cascade is supported only for Connection and AmneziaWG rules, but rule '" +
row.section + "' uses action '" + row.action + "'. Aborted.");
if (row.detour_section == "")
fail_outbound_detour("Outbound cascade is enabled for rule '" + row.section +
"', but no intermediate rule is selected. Aborted.");
if (row.detour_section == row.section)
fail_outbound_detour("Outbound cascade for rule '" + row.section + "' cannot point to itself. Aborted.");
let target = parsed.by_section[row.detour_section];
if (type(target) != "object")
fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references missing rule '" +
row.detour_section + "'. Select an enabled Connection or AmneziaWG rule or disable cascade connection. Aborted.");
if (!target.enabled)
fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references disabled rule '" +
row.detour_section + "'. Select an enabled Connection or AmneziaWG rule or disable cascade connection. Aborted.");
if (!outbound_detour_target_action(target.action))
fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references rule '" +
row.detour_section + "', but it is not a Connection or AmneziaWG rule. Select an enabled Connection or AmneziaWG rule or disable cascade connection. Aborted.");
if (outbound_detour_chain_reaches_source(parsed.by_section, row.section, row.detour_section))
fail_outbound_detour("Outbound cascade for rule '" + row.section + "' creates a cycle through '" +
row.detour_section + "'. Aborted.");
}
}
function validate_outbound_detours() {
validate_outbound_detours_rows(outbound_detour_rows());
}
function basic_rule_rows() {
let rows = [];
for (let line in split(read_stdin(), "\n")) {
line = replace(as_string(line), /\r/g, "");
if (trim(line) == "")
continue;
let fields = split(line, "\t");
push(rows, {
section: as_string(fields[0]),
enabled: bool_flag(fields[1]),
action: as_string(fields[2])
});
}
return rows;
}
function download_section_action_available(action, byedpi_installed, zapret_installed, zapret2_installed) {
action = as_string(action);
if (connections.is_connections_action(action))
return true;
if (action == "awg" || action == "warp" ||
action == "anytls" || action == "snell" || action == "mieru" ||
action == "sudoku" || action == "masque" || action == "openvpn")
return true;
if (action == "byedpi")
return bool_flag(byedpi_installed);
if (action == "zapret")
return bool_flag(zapret_installed);
if (action == "zapret2")
return bool_flag(zapret2_installed);
return false;
}
function validate_download_section_rows(target_section, byedpi_installed, zapret_installed, zapret2_installed, rows) {
target_section = as_string(target_section);
if (target_section == "")
fail_validation("Downloading external resources through a section is enabled, but no download section is selected. Aborted.");
let found = false;
let enabled = false;
let outbound = false;
for (let row in array_or_empty(rows)) {
if (row.section != target_section)
continue;
found = true;
if (!row.enabled)
continue;
enabled = true;
if (download_section_action_available(row.action, byedpi_installed, zapret_installed, zapret2_installed))
outbound = true;
}
if (!found)
fail_validation("Downloading external resources through a section references missing rule '" + target_section +
"'. Select an enabled rule that can provide an outbound or disable the option. Aborted.");
if (!enabled)
fail_validation("Downloading external resources through a section references disabled rule '" + target_section +
"'. Select an enabled rule that can provide an outbound or disable the option. Aborted.");
if (!outbound)
fail_validation("Downloading external resources through a section references rule '" + target_section +
"', but it cannot provide an outbound. Select an enabled Connection, Zapret, Zapret2, or ByeDPI rule with its provider installed, or disable the option. Aborted.");
}
function validate_download_section(target_section, byedpi_installed, zapret_installed, zapret2_installed) {
validate_download_section_rows(target_section, byedpi_installed, zapret_installed, zapret2_installed, basic_rule_rows());
}
function dhcp_has_https_dns_proxy_options(path) {
let data = fs.readfile(as_string(path));
if (data == null)
return null;
return index(data, "doh_backup_noresolv") >= 0 ||
index(data, "doh_backup_server") >= 0 ||
index(data, "doh_server") >= 0;
}
function dhcp_has_https_dns_proxy_options_exit(path) {
let result = dhcp_has_https_dns_proxy_options(path);
exit(result == null ? 2 : (result ? 0 : 1));
}
function mwan3_has_enabled_interface_text(data) {
let prefix = "mwan3.";
let sections = {};
let enabled = {};
for (let line in split(as_string(data), "\n")) {
let equals = index(as_string(line), "=");
if (equals < 0)
continue;
let key = substr(line, 0, equals);
let value = uci_show_list_value(substr(line, equals + 1));
if (!string_starts_with(key, prefix))
continue;
let rest = substr(key, length(prefix));
if (index(rest, ".") < 0) {
if (value == "interface")
sections[key] = true;
continue;
}
let option_dot = rindex(key, ".");
let section = substr(key, 0, option_dot);
let option = substr(key, option_dot + 1);
if (option == "enabled")
enabled[section] = value;
}
for (let section, _ in sections)
if (enabled[section] == "1")
return true;
return false;
}
function mwan3_has_enabled_interface() {
return mwan3_has_enabled_interface_text(read_stdin());
}
function mwan3_has_enabled_interface_from_sections() {
for (let section in uci_core.section_objects("mwan3", "interface"))
if (option(section, "enabled", "0") == "1")
return true;
return false;
}
function mwan3_is_active() {
if (!file_executable("/etc/init.d/mwan3") || !file_nonempty("/etc/config/mwan3"))
return false;
if (!mwan3_has_enabled_interface_from_sections())
return false;
return command_success_from_args([ "/etc/init.d/mwan3", "status" ]) ||
command_success_from_args([ "/etc/init.d/mwan3", "enabled" ]);
}
function hex_digit_value(value) {
let pos = index("0123456789abcdef", lc(as_string(value)));
return pos >= 0 ? pos : null;
}
function parse_number(value) {
value = lc(trim(as_string(value)));
if (value == "")
return null;
if (substr(value, 0, 2) == "0x") {
value = substr(value, 2);
if (value == "")
return null;
let result = 0;
for (let i = 0; i < length(value); i++) {
let digit = hex_digit_value(substr(value, i, 1));
if (digit == null)
return null;
result = result * 16 + digit;
}
return result;
}
return match(value, /^[0-9]+$/) == null ? null : int(value);
}
function subscription_parser() {
if (subscription_parser_module == null)
subscription_parser_module = require("subscription.parser");
return subscription_parser_module;
}
function zapret_validator() {
if (zapret_validator_module == null)
zapret_validator_module = require("providers.zapret.validator");
return zapret_validator_module;
}
function zapret2_validator() {
if (zapret2_validator_module == null)
zapret2_validator_module = require("providers.zapret2.validator");
return zapret2_validator_module;
}
function byedpi_validator() {
if (byedpi_validator_module == null)
byedpi_validator_module = require("providers.byedpi.validator");
return byedpi_validator_module;
}
function wdtt_validator() {
if (wdtt_validator_module == null)
wdtt_validator_module = require("providers.wdtt.validator");
return wdtt_validator_module;
}
function olcrtc_validator() {
if (olcrtc_validator_module == null)
olcrtc_validator_module = require("providers.olcrtc.validator");
return olcrtc_validator_module;
}
function runtime_constants() {
if (constants_module == null)
constants_module = require("core.constants");
return constants_module;
}
function fixture_section_list(type_name) {
let value = object_or_empty(fixture_uci_data)[type_name];
if (type(value) == "array")
return value;
if (type(value) == "object")
return [ value ];
let plural = object_or_empty(fixture_uci_data)[type_name + "s"];
return type(plural) == "array" ? plural : [];
}
function fixture_get_section(section_name) {
let fixture = object_or_empty(fixture_uci_data);
if (section_name == "settings" && type(fixture.settings) == "object")
return fixture.settings;
for (let type_name in [ "settings", "server", "section", "subscription_url", "section_interface", "urltest", "priority_group", "priority_level" ]) {
for (let section in fixture_section_list(type_name)) {
if (as_string(section[".name"]) == section_name)
return section;
}
}
return {};
}
function use_fixture_cursor(path) {
fixture_uci_data = object_or_empty(read_json_file(path));
connections.set_item_sections_from_data(fixture_uci_data);
}
function settings_section() {
if (fixture_uci_data != null)
return object_or_empty(fixture_get_section("settings"));
return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
}
function sections_by_type(type_name) {
if (fixture_uci_data != null)
return fixture_section_list(type_name);
let result = [];
for (let section in uci_core.section_objects(CONFIG_NAME, type_name))
push(result, object_or_empty(section));
return result;
}
function section_name(section) {
return option(section, ".name", "");
}
function section_enabled(section) {
return bool_option(section, "enabled", true);
}
function server_enabled(section) {
return bool_option(section, "enabled", false);
}
function rule_action(section) {
return option(section, "action", "");
}
function rule_action_supported(action) {
return contains([ "connection", "proxy", "outbound", "vpn", "awg", "warp",
"anytls", "snell", "mieru", "sudoku", "masque", "openvpn",
"bypass", "block", "dns", "zapret", "zapret2", "byedpi", "hosts",
"wdtt", "olcrtc" ], as_string(action));
}
function server_routing_section_action_supported(action) {
return contains([ "connection", "proxy", "outbound", "vpn", "awg", "warp",
"anytls", "snell", "mieru", "sudoku", "masque", "openvpn",
"zapret", "zapret2", "byedpi", "wdtt", "olcrtc" ], as_string(action));
}
function duration_to_seconds_value(value) {
let rest = as_string(value);
if (rest == "")
return null;
let total = 0.0;
let multipliers = {
ns: 0.000000001,
us: 0.000001,
ms: 0.001,
s: 1,
m: 60,
h: 3600,
d: 86400
};
while (rest != "") {
let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/);
if (!matched)
return null;
let token = as_string(matched[0]);
let amount = matched[1] * 1;
let unit = matched[3];
total += amount * multipliers[unit];
rest = substr(rest, length(token));
}
return total <= 0 ? null : int(total + 0.5);
}
function validate_duration_option(value, label) {
if (as_string(value) == "")
return;
if (duration_to_seconds_value(value) != null)
return;
fail_validation("Invalid duration value for " + label + ": " + value + ". Use sing-box duration format like 1d, 12h or 30m. Aborted.");
}
function validate_required_duration_option(value, label) {
if (as_string(value) == "")
fail_validation("Missing duration value for " + label + ". Use sing-box duration format like 1d, 12h or 30m. Aborted.");
validate_duration_option(value, label);
}
function dns_server_value_valid(value) {
value = trim(as_string(value));
if (value == "" || match(value, /[ \t\r\n@]/) != null)
return false;
let host = core_url.host(value);
if (host == "")
return false;
let port = core_url.port(value);
if (port != "" && (match(port, /^[0-9]+$/) == null || int(port) < 1 || int(port) > 65535))
return false;
if (core_ip.valid_ip(host))
return true;
return length(host) <= 253 &&
index(host, ".") > 0 &&
substr(host, 0, 1) != "." &&
substr(host, length(host) - 1, 1) != "." &&
match(host, /[\/:\[\]]/) == null;
}
function dns_setting_values(settings, key) {
let values = [];
for (let value in option_list_values(settings, key)) {
value = trim(as_string(value));
if (value != "")
push(values, value);
}
return values;
}
function validate_dns_settings(settings, sections, context) {
let dns_type = option(settings, "dns_type", "udp");
if (!contains([ "udp", "dot", "doh", "doq" ], dns_type))
fail_validation("Unsupported DNS protocol type '" + dns_type + "'. Use udp, dot, doh, or doq. Aborted.");
let dns_strategy = option(settings, "dns_strategy", "prefer_ipv4");
if (!contains([ "prefer_ipv4", "ipv4_only", "prefer_ipv6", "ipv6_only" ], dns_strategy))
fail_validation("Unsupported DNS strategy '" + dns_strategy + "'. Use prefer_ipv4, ipv4_only, prefer_ipv6, or ipv6_only. Aborted.");
let main_servers = dns_setting_values(settings, "dns_server");
let bootstrap_servers = dns_setting_values(settings, "bootstrap_dns_server");
if (length(main_servers) == 0)
fail_validation("At least one main DNS server is required. Aborted.");
if (length(bootstrap_servers) == 0)
fail_validation("At least one Bootstrap DNS server is required. Aborted.");
for (let value in main_servers)
if (!dns_server_value_valid(value))
fail_validation("Invalid main DNS server '" + value + "'. Aborted.");
for (let value in bootstrap_servers)
if (!dns_server_value_valid(value))
fail_validation("Invalid Bootstrap DNS server '" + value + "'. Aborted.");
if (length(main_servers) > 1 || length(bootstrap_servers) > 1) {
validate_required_duration_option(option(settings, "dns_check_interval", "10s"), "settings.dns_check_interval");
validate_required_duration_option(option(settings, "dns_recovery_check_interval", "60s"), "settings.dns_recovery_check_interval");
validate_required_duration_option(option(settings, "dns_check_timeout", "2s"), "settings.dns_check_timeout");
for (let name in [ "dns_failure_threshold", "dns_recovery_threshold", "dns_failover_failure_threshold" ]) {
let value = trim(option(settings, name, "3"));
if (match(value, /^[0-9]+$/) == null || int(value) < 1 || int(value) > 10)
fail_validation("Invalid " + name + " value '" + value + "'. Use a number from 1 to 10. Aborted.");
}
}
// dns_fallback_server must be plain UDP (IP or IP:port, bare hostname or hostname:port).
// DoH/DoT/DoQ URLs (containing "://") and path-style DoH (e.g. "dns.example/dns-query")
// are not valid here because the failover daemon treats fallback servers as plain UDP
// upstreams only. Reject anything that contains "/" as the path separator.
let fallback_servers = dns_setting_values(settings, "dns_fallback_server");
for (let value in fallback_servers) {
if (index(value, "://") >= 0)
fail_validation("Fallback DNS server '" + value + "' looks like a URL. Fallback servers must be plain UDP (IP or IP:port). Aborted.");
if (index(value, "/") >= 0)
fail_validation("Fallback DNS server '" + value + "' contains a path. Fallback servers must be plain UDP (IP or IP:port), not DoH path format. Aborted.");
if (!dns_server_value_valid(value))
fail_validation("Invalid fallback DNS server '" + value + "'. Aborted.");
}
if (!bool_option(settings, "dns_detour_enabled", false))
return;
let target_name = option(settings, "dns_detour_section", "");
if (target_name == "")
fail_validation("DNS through a section is enabled, but no section is selected. Aborted.");
let target = null;
for (let section in sections)
if (section_name(section) == target_name) {
target = {
section: target_name,
enabled: section_enabled(section),
action: rule_action(section)
};
break;
}
if (target == null)
fail_validation("DNS through a section references missing rule '" + target_name + "'. Aborted.");
if (!target.enabled)
fail_validation("DNS through a section references disabled rule '" + target_name + "'. Aborted.");
if (!download_section_action_available(target.action, context.byedpi_installed, context.zapret_installed, context.zapret2_installed))
fail_validation("DNS through a section references rule '" + target_name + "' with unsupported action '" + target.action + "'. Select an enabled Connection, VPN, Zapret, Zapret2, or ByeDPI section. Aborted.");
}
function normalize_port_number_value(value) {
value = trim(as_string(value));
if (value == "" || match(value, /^[0-9]+$/) == null)
return null;
let number = int(value);
return number >= 1 && number <= 65535 ? number : null;
}
function normalize_port_condition_value(value) {
value = trim(as_string(value));
if (value == "")
return null;
let dash = index(value, "-");
if (dash < 0)
return normalize_port_number_value(value);
let start = normalize_port_number_value(substr(value, 0, dash));
let end = normalize_port_number_value(substr(value, dash + 1));
if (start == null || end == null || start > end)
return null;
return start == end ? start : sprintf("%d-%d", start, end);
}
function validate_port_condition_value(value, section) {
if (normalize_port_condition_value(value) != null)
return;
fail_validation("Invalid port condition '" + value + "' in rule '" + section + "'. Use a single port or range like 80 or 1000-2000. Aborted.");
}
function validate_subscription_source_entry_value(entry, section) {
if (index(as_string(entry), "|") >= 0)
fail_validation("Invalid subscription URL in rule '" + section + "': Configure User-Agent in the subscription item settings. Aborted.");
let parsed = subscription_parser().parse_subscription_source_entry(entry);
if (parsed.valid)
return;
fail_validation("Invalid subscription URL in rule '" + section + "': " + parsed.error + ". Aborted.");
}
function valid_http_url(value) {
value = trim(as_string(value));
if (value == "" || match(value, /[ \t\r\n]/) != null)
return false;
let scheme = lc(core_url.scheme(value));
if (scheme != "http" && scheme != "https")
return false;
return core_url.host(value) != "";
}
function validate_http_url_option(value, label) {
if (valid_http_url(value))
return;
fail_validation("Invalid URL value for " + label + ": " + value + ". Use http:// or https:// URL. Aborted.");
}
function validate_country_code_value(value, section) {
if (as_string(value) == "" || country_code_valid(value))
return;
fail_validation("Invalid country code '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_urltest_filter_mode_value(value, section) {
if (as_string(value) == "" || contains([ "disabled", "exclude", "include", "mixed" ], value))
return;
fail_validation("Invalid URLTest filter mode '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_dashboard_filter_mode_value(value, section) {
if (as_string(value) == "" || contains([ "disabled", "exclude", "include", "mixed" ], value))
return;
fail_validation("Invalid dashboard filter mode '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_priority_filter_mode_value(value, section, group_id, level_id) {
if (contains([ "disabled", "exclude", "include", "mixed" ], value))
return;
fail_validation("Invalid Priority filter mode '" + value + "' in rule '" + section + "', priority '" + group_id + "', level '" + level_id + "'. Aborted.");
}
function validate_detect_server_country_value(value, section) {
if (as_string(value) == "" || contains([ "flag_emoji", "country_is" ], value))
return;
fail_validation("Invalid server country detection mode '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_proxy_parameter_value(value, allowed, parameter, section) {
value = lc(as_string(value));
if (value == "" || contains(allowed, value))
return;
fail_validation("Invalid proxy " + parameter + " '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_proxy_parameter_filters(protocols, transports, securities, section) {
for (let value in protocols)
validate_proxy_parameter_value(value,
[ "awg", "direct", "http", "hysteria2", "shadowsocks", "socks", "trojan", "tuic", "vless", "vmess" ],
"protocol", section);
for (let value in transports)
validate_proxy_parameter_value(value,
[ "tcp", "ws", "grpc", "http", "httpupgrade", "xhttp" ],
"transport", section);
for (let value in securities)
validate_proxy_parameter_value(value, [ "none", "tls", "reality" ], "security", section);
}
function validate_urltest_regex_value(value, section) {
if (as_string(value) == "" || regex_valid(value))
return;
fail_validation("Invalid URLTest regular expression '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_urltest_identifier_value(value, section) {
value = as_string(value);
if (value != "" && match(value, /^[A-Za-z0-9_]+$/) != null)
return;
fail_validation("Invalid URLTest identifier '" + value + "' in rule '" + section + "'. Use latin letters, digits and underscores. Aborted.");
}
function validate_priority_identifier_value(value, section) {
value = as_string(value);
if (value != "" && match(value, /^[A-Za-z0-9_]+$/) != null)
return;
fail_validation("Invalid priority identifier '" + value + "' in rule '" + section + "'. Use latin letters, digits and underscores. Aborted.");
}
function validate_urltest_tolerance_value(value, section, urltest_id) {
value = trim(as_string(value));
if (match(value, /^[0-9]+$/) != null) {
let parsed = int(value, 10);
if (parsed >= 0 && parsed <= 10000)
return;
}
fail_validation("Invalid URLTest tolerance '" + value + "' in rule '" + section + "', URLTest '" + urltest_id + "'. Use a number from 0 to 10000. Aborted.");
}
function validate_priority_level_order(value, section, group_id, level_id) {
value = trim(as_string(value));
if (match(value, /^[0-9]+$/) != null)
return;
fail_validation("Invalid priority level order '" + value + "' in rule '" + section + "', priority '" + group_id + "', level '" + level_id + "'. Use a non-negative integer. Aborted.");
}
function validate_priority_group(section, group_id) {
let name = section_name(section);
validate_priority_identifier_value(group_id, name);
if (trim(connections.priority_group_display_name(section, group_id)) == "")
fail_validation("Priority group '" + group_id + "' in rule '" + name + "' has no display name. Aborted.");
validate_http_url_option(connections.priority_group_health_url(section, group_id), "rule." + name + ".priority." + group_id + ".health_url");
validate_required_duration_option(connections.priority_group_active_check_interval(section, group_id), "rule." + name + ".priority." + group_id + ".active_check_interval");
validate_required_duration_option(connections.priority_group_check_timeout(section, group_id), "rule." + name + ".priority." + group_id + ".check_timeout");
validate_required_duration_option(connections.priority_group_recovery_check_interval(section, group_id), "rule." + name + ".priority." + group_id + ".recovery_check_interval");
if (connections.priority_group_switch_to_faster_same_priority(section, group_id))
validate_required_duration_option(connections.priority_group_fastest_check_interval(section, group_id), "rule." + name + ".priority." + group_id + ".fastest_check_interval");
for (let level_id in connections.priority_levels(group_id)) {
validate_priority_identifier_value(level_id, name);
if (trim(connections.priority_level_display_name(group_id, level_id)) == "")
fail_validation("Priority level '" + level_id + "' in rule '" + name + "', priority '" + group_id + "' has no display name. Aborted.");
validate_priority_level_order(connections.priority_level_order(group_id, level_id), name, group_id, level_id);
if (connections.priority_level_direct(group_id, level_id))
continue;
let filter_mode = connections.priority_level_filter_mode(group_id, level_id);
validate_priority_filter_mode_value(filter_mode, name, group_id, level_id);
if (filter_mode != "disabled")
validate_detect_server_country_value(connections.priority_level_detect_server_country(group_id, level_id), name);
if (filter_mode == "include" || filter_mode == "mixed") {
for (let value in connections.priority_level_include_countries(group_id, level_id))
validate_country_code_value(value, name);
for (let value in connections.priority_level_include_regex(group_id, level_id))
validate_urltest_regex_value(value, name);
if (connections.priority_level_include_proxy_parameters(group_id, level_id))
validate_proxy_parameter_filters(
connections.priority_level_include_protocols(group_id, level_id),
connections.priority_level_include_transports(group_id, level_id),
connections.priority_level_include_securities(group_id, level_id),
name
);
}
if (filter_mode == "exclude" || filter_mode == "mixed") {
for (let value in connections.priority_level_exclude_countries(group_id, level_id))
validate_country_code_value(value, name);
for (let value in connections.priority_level_exclude_regex(group_id, level_id))
validate_urltest_regex_value(value, name);
if (connections.priority_level_exclude_proxy_parameters(group_id, level_id))
validate_proxy_parameter_filters(
connections.priority_level_exclude_protocols(group_id, level_id),
connections.priority_level_exclude_transports(group_id, level_id),
connections.priority_level_exclude_securities(group_id, level_id),
name
);
}
}
}
function validate_dashboard_group_references(section, values) {
let available = {};
for (let group_id in connections.urltests(section))
available[connections.urltest_display_name(section, group_id)] = true;
for (let group_id in connections.priority_groups(section))
available[connections.priority_group_display_name(section, group_id)] = true;
for (let group_name in values)
if (!available[group_name])
fail_validation("Unknown dashboard URLTest/Priority group '" + group_name + "' in rule '" + section_name(section) + "'. Aborted.");
}
function validate_dashboard_filter(section) {
let name = section_name(section);
let filter_mode = connections.dashboard_filter_mode(section);
validate_dashboard_filter_mode_value(filter_mode, name);
if (filter_mode == "disabled")
return;
validate_detect_server_country_value(connections.dashboard_detect_server_country(section), name);
if (filter_mode == "include" || filter_mode == "mixed") {
for (let value in connections.dashboard_include_countries(section))
validate_country_code_value(value, name);
for (let value in connections.dashboard_include_regex(section))
validate_urltest_regex_value(value, name);
validate_dashboard_group_references(section, connections.dashboard_include_groups(section));
if (connections.dashboard_include_proxy_parameters(section))
validate_proxy_parameter_filters(
connections.dashboard_include_protocols(section),
connections.dashboard_include_transports(section),
connections.dashboard_include_securities(section),
name
);
}
if (filter_mode == "exclude" || filter_mode == "mixed") {
for (let value in connections.dashboard_exclude_countries(section))
validate_country_code_value(value, name);
for (let value in connections.dashboard_exclude_regex(section))
validate_urltest_regex_value(value, name);
validate_dashboard_group_references(section, connections.dashboard_exclude_groups(section));
if (connections.dashboard_exclude_proxy_parameters(section))
validate_proxy_parameter_filters(
connections.dashboard_exclude_protocols(section),
connections.dashboard_exclude_transports(section),
connections.dashboard_exclude_securities(section),
name
);
}
}
function validate_combined_domain_value(value, section) {
if (as_string(value) == "" || combined_domain_valid(value))
return;
log_message("Ignoring invalid domain condition '" + value + "' in rule '" + section + "'. Use plain domains or full:, keyword:, regex: prefixes.", "warn");
}
function validate_combined_domain_text_value(value, section) {
if (as_string(value) == "")
return;
for (let item in rule_config.text_list_values(value, "comma-space")) {
if (!combined_domain_valid(item))
log_message("Ignoring invalid domain condition '" + item + "' in rule '" + section + "'. Use plain domains or full:, keyword:, regex: prefixes.", "warn");
}
}
function validate_service_value(service, context) {
if (community_service_valid(service, context.community_services))
return;
fail_validation("Invalid service in community lists: " + service + ". Check config and LuCI cache. Aborted.");
}
function validate_ruleset_reference_value(reference, context) {
if (ruleset_reference_valid(reference, context.community_services))
return;
fail_validation("Unknown rule set reference '" + reference + "'. Aborted.");
}
function validate_plain_domain_ip_list_reference_value(reference) {
if (plain_domain_ip_list_reference_valid(reference))
return;
fail_validation("Unknown plain list reference '" + reference + "'. Aborted.");
}
function validate_dscp_values(section) {
let raw = option_list_values(section, "dscp");
for (let item in raw) {
if (connections.parse_dscp_value(item) == null)
fail_validation("Rule '" + section_name(section) + "' has invalid DSCP value '" + item + "'. Expected 0-63, hex 0x00-0x3f, or alias like ef/cs5/af41. Aborted.");
}
}
function validate_geoip_country_values(section) {
let mode = option(section, "geoip_mode", "");
if (mode != "" && mode != "exclude" && mode != "include")
fail_validation("Rule '" + section_name(section) + "' has invalid GeoIP filter mode '" + mode + "'. Expected 'exclude' or 'include'. Aborted.");
let list = connections.geoip_country_list(section);
for (let item in list) {
if (match(item, /^[a-z]{2}$/) == null)
fail_validation("Rule '" + section_name(section) + "' has invalid country code '" + item + "'. Expected 2-letter ISO country code. Aborted.");
}
}
function validate_common_rule_references(section, context) {
for (let value in connections.community_lists(section))
validate_service_value(value, context);
for (let value in connections.rule_sets(section))
validate_ruleset_reference_value(value, context);
for (let value in connections.rule_sets_with_subnets(section))
validate_ruleset_reference_value(value, context);
for (let value in list_option(section, "domain_ip_lists"))
validate_plain_domain_ip_list_reference_value(value);
validate_dscp_values(section);
validate_geoip_country_values(section);
}
function outbound_json_object(value) {
try {
value = json(as_string(value));
}
catch (e) {
return null;
}
return type(value) == "object" ? value : null;
}
function valid_outbound_json(value) {
value = outbound_json_object(value);
return value != null && type(value.type) == "string" && trim(as_string(value.type)) != "";
}
function validate_outbound_json_rule(section) {
let name = section_name(section);
let outbound_json = option(section, "outbound_json", "");
if (outbound_json == "")
fail_validation("JSON outbound rule '" + name + "' has empty outbound_json. Aborted.");
if (!valid_outbound_json(outbound_json))
fail_validation("JSON outbound rule '" + name + "' must contain a valid sing-box outbound JSON object with a type field. Aborted.");
}
function validate_outbound_json_values(section) {
let name = section_name(section);
let values = connections.outbound_jsons(section);
let require_user_tag = length(list_option(section, "outbound_jsons")) > 0;
let tags = {};
for (let value in values) {
if (as_string(value) == "")
fail_validation("Connection rule '" + name + "' has empty JSON outbound. Aborted.");
if (!valid_outbound_json(value))
fail_validation("Connection rule '" + name + "' must contain valid sing-box outbound JSON objects with a type field. Aborted.");
let outbound = outbound_json_object(value);
let tag_name = trim(as_string(outbound.tag || ""));
if (tag_name == "" && require_user_tag)
fail_validation("Connection rule '" + name + "' has JSON outbound without a non-empty tag. Aborted.");
if (tag_name != "" && tags[tag_name])
fail_validation("Connection rule '" + name + "' has duplicate JSON outbound tag '" + tag_name + "'. Aborted.");
if (tag_name != "")
tags[tag_name] = true;
}
}
function rule_has_subscription_urls(section) {
return length(connections.subscription_urls(section)) > 0;
}
function subscription_update_interval_for_source(section, entry) {
if (!connections.subscription_update_enabled(section, entry))
return "";
let value = connections.subscription_update_interval(section, entry);
return value != "" ? value : "1h";
}
function validate_subscription_request_profile(section, entry) {
if (connections.subscription_auto_hwid(section, entry))
return;
if (connections.subscription_hwid(section, entry) != "")
return;
fail_validation("Subscription source in rule '" + section_name(section) + "' has manual HWID enabled but HWID is empty. Fill HWID or enable auto-generation. Aborted.");
}
function validate_provider_strategy(kind, section, context) {
let name = section_name(section);
if (kind == "zapret") {
let raw = zapret_validator().strategy_or_default(option(section, "nfqws_opt", ""), context.zapret_default_nfqws_opt);
let result = zapret_validator().validate_strategy("nfqws", raw, context.zapret_legacy_default_nfqws_opt);
if (!result.valid)
fail_validation("Zapret rule '" + name + "' uses invalid NFQWS strategy: " + result.message + " Aborted.");
return;
}
if (kind == "zapret2") {
let raw = zapret2_validator().strategy_or_default(option(section, "nfqws2_opt", ""), context.zapret2_default_nfqws2_opt);
let result = zapret2_validator().validate_strategy("nfqws2", raw, "");
if (!result.valid)
fail_validation("Zapret2 rule '" + name + "' uses invalid NFQWS2 strategy: " + result.message + " Aborted.");
return;
}
let raw = byedpi_validator().strategy_or_default(option(section, "byedpi_cmd_opts", ""), context.byedpi_default_cmd_opts);
let result = byedpi_validator().validate_byedpi_strategy(raw);
if (!result.valid)
fail_validation("Invalid ByeDPI strategy for rule '" + name + "': " + result.message);
}
function validate_wdtt_section(section, context) {
let name = section_name(section);
let uri = option(section, "warp_flow_uri", "");
if (uri == "") {
fail_validation("WDTT rule '" + name + "' has empty URI. Aborted.");
return;
}
let result = wdtt_validator().parse_wdtt_uri(uri);
if (!result.valid)
fail_validation("Invalid WDTT URI for rule '" + name + "': " + result.message + ". Aborted.");
let mode = option(section, "warp_flow_mode", "");
if (mode != "" && !contains(["freedom", "captive", "camou"], mode))
fail_validation("Invalid WDTT mode '" + mode + "' in rule '" + name + "'. Aborted.");
let device_id = option(section, "warp_flow_device_id", "");
if (device_id != "" && !wdtt_validator().validate_device_id(device_id))
fail_validation("Invalid WDTT device ID '" + device_id + "' in rule '" + name + "'. Aborted.");
}
function validate_olcrtc_section(section, context) {
let name = section_name(section);
let provider = option(section, "olcrtc_provider", "");
if (provider == "") {
fail_validation("OlcRTC rule '" + name + "' has empty provider. Aborted.");
return;
}
if (!contains(["livekit", "wrt"], provider))
fail_validation("Invalid OlcRTC provider '" + provider + "' in rule '" + name + "'. Aborted.");
let transport = option(section, "olcrtc_transport", "whep");
if (!contains(["whep", "whip"], transport))
fail_validation("Invalid OlcRTC transport '" + transport + "' in rule '" + name + "'. Aborted.");
let room_id = option(section, "olcrtc_room_id", "");
if (room_id == "")
fail_validation("OlcRTC rule '" + name + "' has empty room ID. Aborted.");
let crypto_key = option(section, "olcrtc_crypto_key", "");
if (crypto_key == "")
fail_validation("OlcRTC rule '" + name + "' has empty crypto key. Aborted.");
}
function dns_action_has_domain_matchers(section) {
for (let key in [ "domain", "domain_suffix", "domain_keyword", "domain_regex" ])
if (option(section, key, "") != "" || option(section, key + "_text", "") != "" || length(list_option(section, key)) > 0)
return true;
return length(connections.community_lists(section)) > 0 ||
length(connections.rule_sets(section)) > 0 ||
length(list_option(section, "domain_ip_lists")) > 0;
}
function validate_dns_action(section, sections, context) {
let name = section_name(section);
let dns_type = option(section, "dns_type", "udp");
if (!contains([ "udp", "dot", "doh", "doq" ], dns_type))
fail_validation("DNS rule '" + name + "' uses unsupported protocol '" + dns_type + "'. Use udp, dot, doh, or doq. Aborted.");
let dns_servers = list_option(section, "dns_server");
if (length(dns_servers) == 0)
fail_validation("DNS rule '" + name + "' has no DNS server specified. Aborted.");
for (let dns_server in dns_servers) {
if (!dns_server_value_valid(dns_server))
fail_validation("DNS rule '" + name + "' has an invalid DNS server '" + dns_server + "'. Aborted.");
}
if (length(connections.rule_sets_with_subnets(section)) > 0)
fail_validation("DNS rule '" + name + "' can use domain-only rule sets, but subnet extraction is enabled. Disable 'Include IP addresses and subnets'. Aborted.");
let fully_routed_ips = list_option(section, "fully_routed_ips");
if (!dns_action_has_domain_matchers(section) && length(fully_routed_ips) == 0)
fail_validation("DNS rule '" + name + "' must contain at least one domain condition, domain rule set, or forced device. Aborted.");
for (let ip in fully_routed_ips) {
if (!core_ip.valid_ip_or_cidr(ip) && !core_ip.valid_mac(ip))
fail_validation("DNS rule '" + name + "' has an invalid forced device IP, MAC, or subnet '" + ip + "'. Aborted.");
}
for (let ip in list_option(section, "source_ip_cidr")) {
if (!core_ip.valid_ip_or_cidr(ip) && !core_ip.valid_mac(ip))
fail_validation("DNS rule '" + name + "' has an invalid device filter IP, MAC, or subnet '" + ip + "'. Aborted.");
}
if (!bool_option(section, "dns_detour_enabled", false))
return;
let target_name = option(section, "dns_detour_section", "");
if (target_name == "")
fail_validation("DNS rule '" + name + "' routes DNS through a section, but no section is selected. Aborted.");
if (target_name == name)
fail_validation("DNS rule '" + name + "' cannot use itself as a DNS detour. Aborted.");
let target = null;
for (let candidate in sections)
if (section_name(candidate) == target_name) {
target = candidate;
break;
}
if (target == null)
fail_validation("DNS rule '" + name + "' references missing detour section '" + target_name + "'. Aborted.");
if (!section_enabled(target))
fail_validation("DNS rule '" + name + "' references disabled detour section '" + target_name + "'. Aborted.");
if (!download_section_action_available(rule_action(target), context.byedpi_installed, context.zapret_installed, context.zapret2_installed))
fail_validation("DNS rule '" + name + "' references detour section '" + target_name + "' with unsupported action '" + rule_action(target) + "'. Select an enabled Connection, VPN, Zapret, Zapret2, or ByeDPI section. Aborted.");
}
function validate_rule(section, sections, context) {
if (!section_enabled(section))
return;
let name = section_name(section);
let action = rule_action(section);
if (action == "")
fail_validation("Enabled rule '" + name + "' has no action. Aborted.");
if (!rule_action_supported(action))
fail_validation("Enabled rule '" + name + "' uses unsupported action '" + action + "'. Aborted.");
if (action != "dns")
for (let value in list_option(section, "ports"))
validate_port_condition_value(value, name);
if (action == "dns")
validate_dns_action(section, sections, context);
if (action == "zapret") {
if (!context.zapret_installed) {
validate_common_rule_references(section, context);
return;
}
validate_provider_strategy("zapret", section, context);
}
if (action == "zapret2") {
if (!context.zapret2_installed) {
validate_common_rule_references(section, context);
return;
}
validate_provider_strategy("zapret2", section, context);
}
if (action == "byedpi") {
if (!context.byedpi_installed) {
validate_common_rule_references(section, context);
return;
}
validate_provider_strategy("byedpi", section, context);
}
if (action == "wdtt") {
if (!context.wdtt_installed) {
validate_common_rule_references(section, context);
return;
}
validate_wdtt_section(section, context);
}
if (action == "olcrtc") {
if (!context.olcrtc_installed) {
validate_common_rule_references(section, context);
return;
}
validate_olcrtc_section(section, context);
}
if (connections.is_connections_action(action)) {
validate_dashboard_filter(section);
for (let urltest_id in connections.urltests(section)) {
validate_urltest_identifier_value(urltest_id, name);
let urltest_filter_mode = connections.urltest_filter_mode(section, urltest_id);
validate_urltest_filter_mode_value(urltest_filter_mode, name);
if (contains([ "exclude", "include", "mixed" ], urltest_filter_mode))
validate_detect_server_country_value(connections.urltest_detect_server_country(section, urltest_id), name);
validate_required_duration_option(connections.urltest_check_interval(section, urltest_id), "rule." + name + ".urltest." + urltest_id + ".urltest_check_interval");
validate_urltest_tolerance_value(connections.urltest_tolerance(section, urltest_id), name, urltest_id);
validate_http_url_option(connections.urltest_testing_url(section, urltest_id), "rule." + name + ".urltest." + urltest_id + ".testing_url");
let idle_timeout = connections.urltest_idle_timeout(section, urltest_id);
if (idle_timeout != "")
validate_required_duration_option(idle_timeout, "rule." + name + ".urltest." + urltest_id + ".idle_timeout");
if (contains([ "include", "mixed" ], urltest_filter_mode)) {
for (let value in connections.urltest_include_regex(section, urltest_id))
validate_urltest_regex_value(value, name);
for (let value in connections.urltest_include_countries(section, urltest_id))
validate_country_code_value(value, name);
if (connections.urltest_include_proxy_parameters(section, urltest_id))
validate_proxy_parameter_filters(
connections.urltest_include_protocols(section, urltest_id),
connections.urltest_include_transports(section, urltest_id),
connections.urltest_include_securities(section, urltest_id),
name
);
}
if (contains([ "exclude", "mixed" ], urltest_filter_mode)) {
for (let value in connections.urltest_exclude_countries(section, urltest_id))
validate_country_code_value(value, name);
for (let value in connections.urltest_exclude_regex(section, urltest_id))
validate_urltest_regex_value(value, name);
if (connections.urltest_exclude_proxy_parameters(section, urltest_id))
validate_proxy_parameter_filters(
connections.urltest_exclude_protocols(section, urltest_id),
connections.urltest_exclude_transports(section, urltest_id),
connections.urltest_exclude_securities(section, urltest_id),
name
);
}
}
for (let group_id in connections.priority_groups(section))
validate_priority_group(section, group_id);
if (rule_has_subscription_urls(section)) {
for (let value in connections.subscription_urls(section)) {
validate_subscription_source_entry_value(value, name);
validate_subscription_request_profile(section, value);
let subscription_update_interval = subscription_update_interval_for_source(section, value);
if (subscription_update_interval != "")
validate_required_duration_option(subscription_update_interval, "rule." + name + ".subscription_update_interval");
}
}
validate_outbound_json_values(section);
}
if (action == "outbound")
validate_outbound_json_rule(section);
validate_combined_domain_text_value(option(section, "domain", ""), name);
for (let value in list_option(section, "domain_suffix"))
validate_combined_domain_value(value, name);
validate_combined_domain_text_value(option(section, "domain_suffix_text", ""), name);
if (connections.routed_dns_enabled(section)) {
if (action == "dns" || action == "hosts" || action == "bypass" || action == "block")
fail_validation("Rule '" + name + "' has routed DNS enabled but action '" + action + "' does not support routed DNS. Use a proxy action (connection, zapret, etc.). Aborted.");
let routed_type = connections.routed_dns_type(section);
if (!contains(["udp", "dot", "doh", "doq"], routed_type))
fail_validation("Rule '" + name + "' has invalid routed DNS type '" + routed_type + "'. Use udp, dot, doh, or doq. Aborted.");
let routed_servers = connections.routed_dns_servers(section);
if (length(routed_servers) == 0)
fail_validation("Rule '" + name + "' has routed DNS enabled but no DNS server specified. Aborted.");
for (let srv in routed_servers)
if (!dns_server_value_valid(srv))
fail_validation("Rule '" + name + "' has an invalid routed DNS server '" + srv + "'. Aborted.");
if (!connections.has_dns_matchers(section) && length(list_option(section, "fully_routed_ips")) == 0)
fail_validation("Rule '" + name + "' has routed DNS enabled but no domain conditions or forced devices. Routed DNS requires at least one domain matcher. Aborted.");
}
validate_common_rule_references(section, context);
}
function download_via_proxy_option_for_purpose(purpose) {
purpose = as_string(purpose || "lists");
if (purpose == "lists")
return "download_lists_via_proxy";
if (purpose == "components")
return "download_components_via_proxy";
return "";
}
function download_via_proxy_section_option_for_purpose(purpose) {
purpose = as_string(purpose || "lists");
if (purpose == "lists")
return "download_lists_via_proxy_section";
if (purpose == "components")
return "download_components_via_proxy_section";
return "";
}
function download_via_proxy_section(settings, purpose) {
let enabled_option = download_via_proxy_option_for_purpose(purpose);
if (enabled_option == "" || !bool_option(settings, enabled_option, false))
return "";
let section_option = download_via_proxy_section_option_for_purpose(purpose);
let configured = section_option != "" ? option(settings, section_option, "") : "";
if (configured != "")
return configured;
return option(settings, "download_lists_via_proxy_section", "");
}
function download_via_proxy_enabled(settings, purpose) {
let enabled_option = download_via_proxy_option_for_purpose(purpose);
return enabled_option != "" && bool_option(settings, enabled_option, false);
}
function basic_rows_from_sections(sections) {
let rows = [];
for (let section in sections) {
push(rows, {
section: section_name(section),
enabled: section_enabled(section),
action: rule_action(section)
});
}
return rows;
}
function detour_rows_from_sections(sections) {
let rows = [];
for (let section in sections) {
push(rows, {
section: section_name(section),
enabled: section_enabled(section),
action: rule_action(section),
detour_enabled: bool_option(section, "outbound_detour_enabled", false),
detour_section: option(section, "outbound_detour_section", "")
});
}
return rows;
}
function validate_subscription_download_sections(sections, context) {
let rows = basic_rows_from_sections(sections);
for (let section in sections) {
section = object_or_empty(section);
if (!section_enabled(section))
continue;
let name = section_name(section);
for (let entry in connections.subscription_urls(section)) {
let target = connections.subscription_download_section(section, entry);
if (target == "")
continue;
validate_download_section_rows(
target,
context.byedpi_installed,
context.zapret_installed,
context.zapret2_installed,
rows
);
}
}
}
function basic_rows_by_section(rows) {
let result = {};
for (let row in array_or_empty(rows))
if (type(row) == "object" && row.section != "")
result[row.section] = row;
return result;
}
function validate_server_routing_sections(sections) {
let by_section = basic_rows_by_section(basic_rows_from_sections(sections));
for (let server in sections_by_type("server")) {
server = object_or_empty(server);
if (!server_enabled(server))
continue;
let name = section_name(server);
let mode = option(server, "routing_mode", "rules");
if (!contains([ "rules", "direct", "section" ], mode))
fail_validation("Server '" + name + "' uses unsupported routing mode '" + mode + "'. Aborted.");
if (option(server, "protocol", "vless") == "mtproto") {
let faketls = option(server, "mtproto_faketls", "google.com");
if (common.mtproto_secret_canonical(option(server, "mtproto_secret", ""), faketls) == null)
fail_validation("Server '" + name + "' has an invalid MTProto secret. Provide the full 'ee' secret or a 16-byte key in hex or base64 form. Aborted.");
}
if (mode != "section")
continue;
let target_name = option(server, "routing_section", "");
if (target_name == "")
fail_validation("Server '" + name + "' uses selected-section routing, but no routing section is selected. Aborted.");
let target = by_section[target_name];
if (type(target) != "object")
fail_validation("Server '" + name + "' references missing routing section '" + target_name + "'. Aborted.");
if (!target.enabled)
fail_validation("Server '" + name + "' references disabled routing section '" + target_name + "'. Aborted.");
if (!server_routing_section_action_supported(target.action))
fail_validation("Server '" + name + "' references routing section '" + target_name + "' with unsupported action '" + target.action + "'. Select a Connection, proxy, JSON outbound, VPN, AWG, Zapret, Zapret2, or ByeDPI section. Aborted.");
}
}
function validate_list_update_settings(settings) {
if (!bool_option(settings, "list_update_enabled", true))
return;
let update_interval = option(settings, "update_interval", "1d");
if (update_interval == "")
update_interval = "1d";
validate_required_duration_option(update_interval, "settings.update_interval");
}
function validate_runtime_mark_ranges_context(context) {
let fakeip_mark = parse_number(context.nft_fakeip_mark);
let outbound_mark = parse_number(context.nft_outbound_mark);
if (fakeip_mark == null || outbound_mark == null)
fail_validation("Tachyon marks contain invalid numeric constants. Aborted.");
if ((fakeip_mark & TAILSCALE_FWMARK_MASK) != 0)
fail_validation("FakeIP mark overlaps Tailscale fwmark mask 0x00ff0000. Aborted.");
if ((outbound_mark & TAILSCALE_FWMARK_MASK) != 0)
fail_validation("Outbound mark overlaps Tailscale fwmark mask 0x00ff0000. Aborted.");
let ranges = [
[ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ],
[ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ],
[ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ],
[ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ],
[ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, TAILSCALE_FWMARK_MASK, "Tailscale fwmark mask 0x00ff0000" ],
[ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, TAILSCALE_FWMARK_MASK, "Tailscale fwmark mask 0x00ff0000" ]
];
for (let range in ranges) {
let label = as_string(range[0]);
let base = parse_number(range[1]);
let range_size = parse_number(range[2]);
let mark_mask = range[3];
let mark_mask_label = as_string(range[4]);
if (base == null || range_size == null || mark_mask == null)
fail_validation(label + " route mark range contains invalid numeric constants. Aborted.");
for (let index_value = 1; index_value <= range_size; index_value++) {
let mark_value = base + index_value;
if ((mark_value & mark_mask) != 0)
fail_validation(label + " route mark range overlaps " + mark_mask_label + ". Aborted.");
}
}
}
function validate_time_format(val, label) {
val = trim(as_string(val));
if (val == "") return;
if (!match(val, /^([01][0-9]|2[0-3]):[0-5][0-9]$/))
fail_validation("Schedule option '" + label + "' has invalid time format '" + val + "'. Expected HH:MM. Aborted.");
}
function validate_schedule(schedule, sections, profiles) {
if (!section_enabled(schedule))
return;
let name = section_name(schedule);
let start_time = option(schedule, "start_time", "");
let end_time = option(schedule, "end_time", "");
validate_time_format(start_time, "schedule." + name + ".start_time");
validate_time_format(end_time, "schedule." + name + ".end_time");
let raw_ips = list_option(schedule, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
// Daily time quota (minutes per day); 0/empty disables the limit.
let quota_raw = trim(as_string(option(schedule, "daily_quota_minutes", "") || ""));
if (quota_raw != "") {
let quota = int(quota_raw);
if (quota < 0 || quota > 1440 || match(quota_raw, /^[0-9]+$/) == null)
fail_validation("Schedule '" + name + "' has invalid daily_quota_minutes '" + quota_raw + "'. Use a number of minutes between 0 and 1440. Aborted.");
}
for (let ip in raw_ips) {
let clean_ip = trim(as_string(ip));
let is_mac = match(clean_ip, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (clean_ip != "" && !core_ip.valid_ip_or_cidr(clean_ip) && !is_mac)
fail_validation("Schedule '" + name + "' has invalid device IP or MAC address '" + clean_ip + "'. Aborted.");
}
let profile_refs = list_option(schedule, "profile");
if (length(profile_refs) == 0) {
let single_p = option(schedule, "profile", "");
if (single_p != "") profile_refs = [ single_p ];
}
for (let prof_name in profile_refs) {
let found = false;
for (let p in profiles) {
if (section_name(p) == prof_name) {
found = true;
break;
}
}
if (!found)
fail_validation("Schedule '" + name + "' references unknown profile '" + prof_name + "'. Aborted.");
}
let target = option(schedule, "target", "all");
let sec_names = list_option(schedule, "sections");
if (length(sec_names) == 0) {
let single_sec = option(schedule, "sections", "");
if (single_sec != "") sec_names = [ single_sec ];
else if (target != "all" && target != "sections" && target != "domains" && target != "") sec_names = [ target ];
}
for (let sec_name in sec_names) {
let found = false;
for (let sec in sections) {
if (section_name(sec) == sec_name) {
found = true;
break;
}
}
if (!found)
fail_validation("Schedule '" + name + "' references unknown section '" + sec_name + "'. Aborted.");
}
let blocked_domains = list_option(schedule, "blocked_domains");
if (length(blocked_domains) == 0) {
let single_domain = option(schedule, "blocked_domains", "");
if (single_domain != "") blocked_domains = [ single_domain ];
}
for (let domain in blocked_domains) {
let clean_domain = trim(as_string(domain));
if (clean_domain != "" && !combined_domain_valid(clean_domain))
fail_validation("Schedule '" + name + "' has invalid blocked domain '" + clean_domain + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted.");
}
let mode = option(schedule, "mode", "block");
if (mode != "" && mode != "block" && mode != "allow")
fail_validation("Schedule '" + name + "' has invalid mode '" + mode + "'. Expected 'block' or 'allow'. Aborted.");
let dns_level = option(schedule, "dns_level", "1");
if (dns_level != "" && dns_level != "0" && dns_level != "1")
fail_validation("Schedule '" + name + "' has invalid dns_level '" + dns_level + "'. Expected 0 or 1. Aborted.");
let notify = option(schedule, "notify", "0");
if (notify != "" && notify != "0" && notify != "1")
fail_validation("Schedule '" + name + "' has invalid notify '" + notify + "'. Expected 0 or 1. Aborted.");
}
function validate_profile(profile, profiles) {
if (!section_enabled(profile))
return;
let name = section_name(profile);
let raw_ips = list_option(profile, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(profile, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
for (let ip in raw_ips) {
let clean_ip = trim(as_string(ip));
let is_mac = match(clean_ip, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (clean_ip != "" && !core_ip.valid_ip_or_cidr(clean_ip) && !is_mac)
fail_validation("Profile '" + name + "' has invalid device IP or MAC address '" + clean_ip + "'. Aborted.");
}
let quota_raw = trim(as_string(option(profile, "daily_quota_minutes", "") || ""));
if (quota_raw != "") {
let quota = int(quota_raw);
if (quota < 0 || quota > 1440 || match(quota_raw, /^[0-9]+$/) == null)
fail_validation("Profile '" + name + "' has invalid daily_quota_minutes '" + quota_raw + "'. Use a number of minutes between 0 and 1440. Aborted.");
}
let safe_search = option(profile, "safe_search", "0");
if (safe_search != "" && safe_search != "0" && safe_search != "1")
fail_validation("Profile '" + name + "' has invalid safe_search '" + safe_search + "'. Expected 0 or 1. Aborted.");
let block_doh = option(profile, "block_doh", "0");
if (block_doh != "" && block_doh != "0" && block_doh != "1")
fail_validation("Profile '" + name + "' has invalid block_doh '" + block_doh + "'. Expected 0 or 1. Aborted.");
let notify = option(profile, "notify", "0");
if (notify != "" && notify != "0" && notify != "1")
fail_validation("Profile '" + name + "' has invalid notify '" + notify + "'. Expected 0 or 1. Aborted.");
let blocked_domains = list_option(profile, "blocked_domains");
if (length(blocked_domains) == 0) {
let single_domain = option(profile, "blocked_domains", "");
if (single_domain != "") blocked_domains = [ single_domain ];
}
for (let domain in blocked_domains) {
let clean_domain = trim(as_string(domain));
if (clean_domain != "" && !combined_domain_valid(clean_domain))
fail_validation("Profile '" + name + "' has invalid blocked domain '" + clean_domain + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted.");
}
}
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);
// Native-mode Tailscale servers need the system tailscaled binary; the
// check lives here so every validation entry point (including fixtures)
// enforces it.
for (let ts_server in sections_by_type("server")) {
if (!server_enabled(ts_server))
continue;
if (option(ts_server, "protocol", "vless") != "tailscale")
continue;
if (option(ts_server, "tailscale_mode", "singbox") != "native")
continue;
// Without a key `tailscale up` falls back to interactive login and
// would block the runtime forever on a headless router.
if (trim(as_string(option(ts_server, "tailscale_auth_key", "") || "")) == "")
fail_requirement("Server '" + section_name(ts_server) + "' uses native Tailscale mode without an auth key. Generate one in the Tailscale admin console and fill in 'Tailscale auth key', or switch this server back to sing-box mode. Aborted.", "fatal");
if (!command_exists("tailscaled"))
fail_requirement("Server '" + section_name(ts_server) + "' uses native Tailscale mode, but tailscaled is not installed. Install the Tailscale component on the Updates tab, or switch this server back to sing-box mode. Aborted.", "fatal");
}
validate_dns_settings(settings, sections, context);
validate_list_update_settings(settings);
validate_http_url_option(option(settings, "latency_test_url", DEFAULT_LATENCY_TEST_URL) || DEFAULT_LATENCY_TEST_URL, "settings.latency_test_url");
if (download_via_proxy_enabled(settings, "lists")) {
validate_download_section_rows(
download_via_proxy_section(settings, "lists"),
context.byedpi_installed,
context.zapret_installed,
context.zapret2_installed,
basic_rows_from_sections(sections)
);
}
if (download_via_proxy_enabled(settings, "components")) {
validate_download_section_rows(
download_via_proxy_section(settings, "components"),
context.byedpi_installed,
context.zapret_installed,
context.zapret2_installed,
basic_rows_from_sections(sections)
);
}
validate_outbound_detours_rows(detour_rows_from_sections(sections));
validate_subscription_download_sections(sections, context);
validate_server_routing_sections(sections);
for (let section in sections)
validate_rule(section, sections, context);
for (let profile in profiles)
validate_profile(profile, profiles);
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() {
let constants = object_or_empty(runtime_constants());
return {
constants,
community_services: constant_value(constants, "COMMUNITY_SERVICES"),
byedpi_default_cmd_opts: constant_value(constants, "BYEDPI_DEFAULT_CMD_OPTS"),
zapret_default_nfqws_opt: constant_value(constants, "ZAPRET_DEFAULT_NFQWS_OPT"),
zapret_legacy_default_nfqws_opt: constant_value(constants, "ZAPRET_LEGACY_DEFAULT_NFQWS_OPT"),
zapret2_default_nfqws2_opt: constant_value(constants, "ZAPRET2_DEFAULT_NFQWS2_OPT"),
byedpi_installed: file_executable(constant_value(constants, "BYEDPI_BIN")),
zapret_installed: file_executable(constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN")),
zapret2_installed: file_executable(constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN")),
wdtt_installed: file_executable(constant_value(constants, "WDTT_BIN")),
olcrtc_installed: file_executable(constant_value(constants, "OLCRTC_BIN")),
zapret_provider_nfqws_bin: constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN"),
zapret2_provider_nfqws2_bin: constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN"),
zapret_route_mark_base: constant_value(constants, "ZAPRET_ROUTE_MARK_BASE"),
zapret_queue_range_size: constant_value(constants, "ZAPRET_QUEUE_RANGE_SIZE"),
zapret2_route_mark_base: constant_value(constants, "ZAPRET2_ROUTE_MARK_BASE"),
zapret2_queue_range_size: constant_value(constants, "ZAPRET2_QUEUE_RANGE_SIZE"),
nft_fakeip_mark: constant_value(constants, "NFT_FAKEIP_MARK"),
nft_outbound_mark: constant_value(constants, "NFT_OUTBOUND_MARK"),
coreutils_base64_required_version: constant_value(constants, "COREUTILS_BASE64_REQUIRED_VERSION"),
sing_box_required_version: constant_value(constants, "SB_REQUIRED_VERSION"),
sing_box_variant_state_file: constant_value(constants, "SB_VARIANT_STATE_FILE"),
sing_box_version_state_file: constant_value(constants, "SB_VERSION_STATE_FILE"),
sing_box_managed_service_marker: constant_value(constants, "SB_MANAGED_SERVICE_MARKER"),
zapret_legacy_runtime_base_dir: constant_value(constants, "ZAPRET_LEGACY_RUNTIME_BASE_DIR"),
zapret_state_dir: constant_value(constants, "ZAPRET_STATE_DIR"),
zapret_pid_dir: constant_value(constants, "ZAPRET_PID_DIR"),
zapret_child_pid_dir: constant_value(constants, "ZAPRET_CHILD_PID_DIR"),
zapret_log_dir: constant_value(constants, "ZAPRET_LOG_DIR"),
zapret2_state_dir: constant_value(constants, "ZAPRET2_STATE_DIR"),
zapret2_pid_dir: constant_value(constants, "ZAPRET2_PID_DIR"),
zapret2_child_pid_dir: constant_value(constants, "ZAPRET2_CHILD_PID_DIR"),
zapret2_log_dir: constant_value(constants, "ZAPRET2_LOG_DIR"),
byedpi_bin: constant_value(constants, "BYEDPI_BIN"),
byedpi_state_dir: constant_value(constants, "BYEDPI_STATE_DIR"),
byedpi_pid_dir: constant_value(constants, "BYEDPI_PID_DIR"),
byedpi_child_pid_dir: constant_value(constants, "BYEDPI_CHILD_PID_DIR"),
byedpi_log_dir: constant_value(constants, "BYEDPI_LOG_DIR")
};
}
function sing_box_variant_marker(ctx, value) {
let path = as_string(ctx.sing_box_variant_state_file);
if (path == "")
path = "/etc/tachyon/sing-box-variant";
let data = fs.readfile(path);
return trim(as_string(data)) == as_string(value);
}
function sing_box_compressed_marker_set(ctx) {
return sing_box_variant_marker(ctx, "extended-compressed");
}
function sing_box_extended_marker_set(ctx) {
return sing_box_variant_marker(ctx, "extended");
}
function sing_box_lx_marker_set(ctx) {
return sing_box_variant_marker(ctx, "lx");
}
function sing_box_version_state(ctx) {
let path = as_string(ctx.sing_box_version_state_file);
if (path == "")
path = "/etc/tachyon/sing-box-version";
let data = fs.readfile(path);
if (data == null)
return "";
let newline = index(data, "\n");
return newline >= 0 ? substr(data, 0, newline) : as_string(data);
}
function get_sing_box_version(ctx) {
if (!command_exists("sing-box"))
return "";
if (sing_box_compressed_marker_set(ctx) || sing_box_lx_marker_set(ctx))
return sing_box_version_state(ctx);
return first_line_last_field(command_output_from_args([ "sing-box", "version" ]));
}
function sing_box_version_is_extended(version) {
return index(as_string(version), "extended") >= 0;
}
function sing_box_version_is_lx(version) {
return index(as_string(version), "-lx") >= 0;
}
function sing_box_is_extended(ctx, version) {
if (as_string(version) == "" && command_exists("sing-box") &&
(sing_box_compressed_marker_set(ctx) || sing_box_extended_marker_set(ctx) || sing_box_lx_marker_set(ctx)))
return true;
version = version != null ? version : get_sing_box_version(ctx);
return sing_box_version_is_extended(version) || sing_box_version_is_lx(version);
}
function sing_box_is_lx(ctx, version) {
if (as_string(version) == "" && command_exists("sing-box") && sing_box_lx_marker_set(ctx))
return true;
return sing_box_version_is_lx(version != null ? version : get_sing_box_version(ctx));
}
function sing_box_output_has_build_tag(output, tag) {
tag = as_string(tag);
if (tag == "")
return false;
for (let token in split(replace(as_string(output), /[,: \t\r\n]+/g, " "), " "))
if (token == tag)
return true;
return false;
}
function sing_box_supports_tailscale(ctx, version, version_output) {
if (command_exists("sing-box") && (sing_box_compressed_marker_set(ctx) || sing_box_lx_marker_set(ctx)))
return true;
if (sing_box_is_extended(ctx, version))
return true;
if (as_string(version_output) == "")
version_output = command_output_from_args([ "sing-box", "version" ]);
return sing_box_output_has_build_tag(version_output, "with_tailscale");
}
function sing_box_supports_xhttp(ctx, version, version_output) {
if (command_exists("sing-box") && (sing_box_compressed_marker_set(ctx) || sing_box_lx_marker_set(ctx)))
return true;
if (sing_box_is_extended(ctx, version) || sing_box_is_lx(ctx, version))
return true;
if (as_string(version_output) == "")
version_output = command_output_from_args([ "sing-box", "version" ]);
return sing_box_output_has_build_tag(version_output, "with_xhttp");
}
function managed_sing_box_service_script(marker) {
marker = as_string(marker);
if (marker == "")
marker = "Tachyon managed sing-box service for binary variants";
return "#!/bin/sh /etc/rc.common\n" +
"# " + marker + "\n" +
"\n" +
"USE_PROCD=1\n" +
"START=99\n" +
"PROG=\"/usr/bin/sing-box\"\n" +
"\n" +
"start_service() {\n" +
" [ -d /dev/net ] || mkdir -p /dev/net\n" +
" [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200 2>/dev/null || true\n" +
" modprobe tun 2>/dev/null || true\n" +
" modprobe inet_diag 2>/dev/null || true\n" +
"\n" +
" config_load \"sing-box\"\n" +
" local enabled config_file working_directory\n" +
" local log_stderr\n" +
"\n" +
" config_get_bool enabled \"main\" \"enabled\" \"0\"\n" +
" [ \"$enabled\" -eq \"1\" ] || return 0\n" +
"\n" +
" config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" +
" config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" +
" config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n" +
"\n" +
" procd_open_instance\n" +
" procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" +
" procd_set_param file \"$config_file\"\n" +
" procd_set_param stderr \"$log_stderr\"\n" +
" procd_set_param limits core=\"unlimited\"\n" +
" procd_set_param limits nofile=\"1000000 1000000\"\n" +
" procd_set_param env GODEBUG=\"madvdontneed=1\" GOGC=\"50\"\n" +
" procd_set_param term_timeout 15\n" +
" procd_set_param respawn\n" +
" procd_close_instance\n" +
"}\n" +
"\n" +
"service_triggers() {\n" +
" procd_add_reload_trigger \"sing-box\"\n" +
"}\n";
}
function install_managed_sing_box_service_script(ctx) {
let stamp = clock();
let tmp_file = sprintf("/etc/init.d/sing-box.tachyon.%d.%d", stamp[0], stamp[1]);
if (!fs.writefile(tmp_file, managed_sing_box_service_script(ctx.sing_box_managed_service_marker)))
return false;
if (!run_args([ "chmod", "0755", tmp_file ]) || !run_args([ "mv", "-f", tmp_file, "/etc/init.d/sing-box" ])) {
fs.unlink(tmp_file);
return false;
}
return true;
}
function service_exists(service) {
return file_executable("/etc/init.d/" + as_string(service));
}
function validate_extended_server_features(ctx, sing_box_version, sing_box_version_output) {
if (sing_box_is_extended(ctx, sing_box_version))
return;
for (let section in sections_by_type("server")) {
if (!server_enabled(section))
continue;
let name = section_name(section);
let protocol = option(section, "protocol", "vless");
let transport = option(section, "transport", "tcp");
if (protocol == "mtproto")
fail_requirement("Server '" + name + "' uses MTProto proxy, but sing-box-extended is not installed. Install sing-box-extended or disable this server. Aborted.", "fatal");
// Native-mode Tailscale runs tailscaled outside sing-box and does
// not need the with_tailscale build.
if (protocol == "tailscale" && option(section, "tailscale_mode", "singbox") != "native" &&
!sing_box_supports_tailscale(ctx, sing_box_version, sing_box_version_output))
fail_requirement("Server '" + name + "' uses Tailscale, but the installed sing-box binary was built without Tailscale support. Install full sing-box or sing-box-extended, switch this server to native Tailscale mode, or disable this server. Aborted.", "fatal");
if (transport == "xhttp" && !sing_box_supports_xhttp(ctx, sing_box_version, sing_box_version_output))
fail_requirement("Server '" + name + "' uses XHTTP transport, but the installed sing-box binary does not support it. Install sing-box-extended or a binary with XHTTP support, or change the transport. Aborted.", "fatal");
}
}
// Runs for every variant (unlike validate_extended_server_features, which
// early-returns on extended-family binaries): section actions emit
// outbound/endpoint shapes that differ per sing-box build.
function validate_section_action_variant_support(ctx, sing_box_version) {
// Returns true when at least one removed junk-signature field is set.
function removed_junk_sig_set(section) {
for (let key in [ "awg_j1", "awg_j2", "awg_j3", "awg_itime" ]) {
let val = trim(as_string(option(section, key, "")));
if (val != "" && val != "0")
return true;
}
return false;
}
for (let section in sections_by_type("section")) {
if (!section_enabled(section))
continue;
let name = section_name(section);
let action = rule_action(section);
if (action == "awg" && !sing_box_is_extended(ctx, sing_box_version) && !sing_box_is_lx(ctx, sing_box_version))
fail_requirement("Section '" + name + "' uses AmneziaWG, but the installed sing-box binary does not support it. Install sing-box-extended or sing-box-lx, or change the action. Aborted.", "fatal");
if (action == "awg") {
if (removed_junk_sig_set(section) &&
!sing_box_is_lx(ctx, sing_box_version) &&
!common.extended_awg_schema_has_junk_signatures(sing_box_version))
fail_requirement("Section '" + name + "' sets awg_j1/awg_j2/awg_j3/awg_itime, but sing-box-extended removed these fields in 2.6.1 and aborts on unknown fields. Remove them from the section or use sing-box-extended 2.6.0 or older. Aborted.", "fatal");
}
// These actions emit outbound/endpoint shapes that only exist in the
// sing-box-extended fork: "warp" and the profiles[]-style "masque"/
// "openvpn" endpoints are absent from stock sing-box, and sing-box-lx
// uses a different masque schema that our UCI options cannot fill.
// Gating here keeps an invalid section from failing the whole config.
let extended_only_labels = {
warp: "WARP",
masque: "MASQUE",
openvpn: "OpenVPN",
snell: "Snell",
mieru: "Mieru",
sudoku: "Sudoku"
};
let extended_only_label = extended_only_labels[as_string(action)];
// NB: sing_box_is_extended() also matches sing-box-lx, so exclude it here.
if (extended_only_label != null &&
!(sing_box_is_extended(ctx, sing_box_version) && !sing_box_is_lx(ctx, sing_box_version)))
fail_requirement("Section '" + name + "' uses " + extended_only_label + ", but this feature requires the sing-box-extended binary (not supported by sing-box-lx or stock sing-box). Install sing-box-extended or change the action. Aborted.", "fatal");
}
for (let server in sections_by_type("server")) {
if (!server_enabled(server))
continue;
if (as_string(option(server, "protocol", "vless")) != "awg")
continue;
let name = section_name(server);
if (removed_junk_sig_set(server) &&
!sing_box_is_lx(ctx, sing_box_version) &&
!common.extended_awg_schema_has_junk_signatures(sing_box_version))
fail_requirement("Server '" + name + "' sets awg_j1/awg_j2/awg_j3/awg_itime, but sing-box-extended removed these fields in 2.6.1 and aborts on unknown fields. Remove them from the server or use sing-box-extended 2.6.0 or older. Aborted.", "fatal");
}
}
function has_outbound_section(ctx) {
for (let section in sections_by_type("section")) {
if (!section_enabled(section))
continue;
let action = rule_action(section);
if (action == "byedpi" && ctx.byedpi_installed)
return true;
if (action == "zapret" && ctx.zapret_installed)
return true;
if (action == "zapret2" && ctx.zapret2_installed)
return true;
// Tunnel-style actions generate real sing-box endpoints/outbounds;
// without this check a lone AWG/WARP/VPN section triggered the
// misleading "No proxy outbound sections found" warning.
if (contains([ "awg", "warp", "vpn", "openvpn", "masque",
"anytls", "snell", "mieru", "sudoku" ], as_string(action)))
return true;
if (length(connections.connection_urls(section)) > 0 ||
length(connections.subscription_urls(section)) > 0 ||
length(connections.outbound_jsons(section)) > 0 ||
length(connections.interfaces(section)) > 0)
return true;
}
return false;
}
function has_enabled_rule_action(action) {
action = as_string(action);
for (let section in sections_by_type("section"))
if (section_enabled(section) && rule_action(section) == action)
return true;
return false;
}
function cleanup_legacy_zapret_runtime(ctx) {
let base = as_string(ctx.zapret_legacy_runtime_base_dir);
if (base == "")
return;
let needle = base + "/nfq/nfqws";
for (let line in split(command_output_from_args([ "ps", "w" ]), "\n")) {
if (index(as_string(line), needle) < 0)
continue;
let fields = split(trim(as_string(line)), /[ \t]+/);
let pid = length(fields) > 0 ? as_string(fields[0]) : "";
if (match(pid, /^[0-9]+$/) != null)
run_args([ "kill", pid ]);
}
safe_rm_rf(base);
}
function check_provider_requirement(action, display_name, bin_path, dirs, missing_message, prepare_failure_message) {
if (!has_enabled_rule_action(action))
return;
if (!file_executable(bin_path)) {
log_message(missing_message, "error");
return;
}
if (!mkdir_p(dirs))
fail_requirement(prepare_failure_message, "fatal");
}
function check_provider_requirements(ctx) {
cleanup_legacy_zapret_runtime(ctx);
check_provider_requirement(
"zapret",
"Zapret",
ctx.zapret_provider_nfqws_bin,
[ ctx.zapret_state_dir, ctx.zapret_pid_dir, ctx.zapret_child_pid_dir, ctx.zapret_log_dir ],
"Zapret provider is not available at " + ctx.zapret_provider_nfqws_bin + ". Rules with action 'zapret' will be skipped until the zapret provider is installed.",
"Failed to prepare the Tachyon zapret state directory in " + ctx.zapret_state_dir + ". Aborted."
);
check_provider_requirement(
"zapret2",
"Zapret2",
ctx.zapret2_provider_nfqws2_bin,
[ ctx.zapret2_state_dir, ctx.zapret2_pid_dir, ctx.zapret2_child_pid_dir, ctx.zapret2_log_dir ],
"Zapret2 provider is not available at " + ctx.zapret2_provider_nfqws2_bin + ". Rules with action 'zapret2' will be skipped until the zapret2 provider is installed.",
"Failed to prepare the Tachyon zapret2 state directory in " + ctx.zapret2_state_dir + ". Aborted."
);
check_provider_requirement(
"byedpi",
"ByeDPI",
ctx.byedpi_bin,
[ ctx.byedpi_state_dir, ctx.byedpi_pid_dir, ctx.byedpi_child_pid_dir, ctx.byedpi_log_dir ],
"ByeDPI provider is not available at " + ctx.byedpi_bin + ". Rules with action 'byedpi' will be skipped until the byedpi package is installed.",
"Failed to prepare the Tachyon ByeDPI state directory in " + ctx.byedpi_state_dir + ". Aborted."
);
}
function check_runtime_requirements() {
log_message("Checking required packages and runtime settings", "info");
let ctx = context_from_runtime();
let sing_box_version_output = command_exists("sing-box") ? command_output_from_args([ "sing-box", "version" ]) : "";
let sing_box_version = (sing_box_compressed_marker_set(ctx) || sing_box_lx_marker_set(ctx)) ? sing_box_version_state(ctx) : first_line_last_field(sing_box_version_output);
let coreutils_base64_version = first_line_field_from_text(command_output("base64 --version 2>/dev/null"), 4);
if (sing_box_version == "") {
if (!command_exists("sing-box") || !(sing_box_compressed_marker_set(ctx) || sing_box_lx_marker_set(ctx)))
fail_requirement("Package 'sing-box' is not installed. Aborted.", "error");
}
else if (!version_at_least(sing_box_version, ctx.sing_box_required_version)) {
fail_requirement("Package 'sing-box' version (" + sing_box_version + ") is lower than the required minimum (" + ctx.sing_box_required_version + "). Update sing-box: opkg update && opkg remove sing-box && opkg install sing-box. Aborted.", "error");
}
if (!service_exists("sing-box") && (sing_box_compressed_marker_set(ctx) || sing_box_lx_marker_set(ctx)))
install_managed_sing_box_service_script(ctx);
if (!service_exists("sing-box"))
fail_requirement("Service 'sing-box' is missing. Install a sing-box package or reinstall sing-box-lx / sing-box-extended binary variant. Aborted.", "error");
validate_extended_server_features(ctx, sing_box_version, sing_box_version_output);
validate_section_action_variant_support(ctx, sing_box_version);
if (coreutils_base64_version == "")
fail_requirement("Package 'coreutils-base64' is not installed. Aborted.", "error");
else if (!version_at_least(coreutils_base64_version, ctx.coreutils_base64_required_version))
log_message("Package 'coreutils-base64' version (" + coreutils_base64_version + ") is lower than the required minimum (" + ctx.coreutils_base64_required_version + "). This may cause issues when decoding base64 streams with missing padding, as automatic padding support is not available in older versions.", "warn");
if (dhcp_has_https_dns_proxy_options("/etc/config/dhcp") === true)
log_message("https-dns-proxy is enabled in DHCP config. Disable it or edit /etc/config/dhcp before starting Tachyon.", "error");
if (has_outbound_section(ctx))
log_message("Proxy outbound configuration found", "debug");
else
log_message("No proxy outbound sections found. Tachyon will use direct and/or provider-only routing.", "warn");
check_provider_requirements(ctx);
}
function context_override_value(overrides, defaults, key) {
let value = object_or_empty(overrides)[key];
if (value != null)
return as_string(value);
return as_string(object_or_empty(defaults)[key]);
}
function context_override_bool(overrides, defaults, key) {
let value = object_or_empty(overrides)[key];
if (value != null)
return bool_flag(value);
return bool_flag(object_or_empty(defaults)[key]);
}
function context_from_json(value, defaults) {
try {
value = json(as_string(value));
}
catch (e) {
value = {};
}
value = object_or_empty(value);
defaults = object_or_empty(defaults);
return {
community_services: context_override_value(value, defaults, "community_services"),
byedpi_default_cmd_opts: context_override_value(value, defaults, "byedpi_default_cmd_opts"),
zapret_default_nfqws_opt: context_override_value(value, defaults, "zapret_default_nfqws_opt"),
zapret_legacy_default_nfqws_opt: context_override_value(value, defaults, "zapret_legacy_default_nfqws_opt"),
zapret2_default_nfqws2_opt: context_override_value(value, defaults, "zapret2_default_nfqws2_opt"),
byedpi_installed: context_override_bool(value, defaults, "byedpi_installed"),
zapret_installed: context_override_bool(value, defaults, "zapret_installed"),
zapret2_installed: context_override_bool(value, defaults, "zapret2_installed"),
zapret_route_mark_base: context_override_value(value, defaults, "zapret_route_mark_base"),
zapret_queue_range_size: context_override_value(value, defaults, "zapret_queue_range_size"),
zapret2_route_mark_base: context_override_value(value, defaults, "zapret2_route_mark_base"),
zapret2_queue_range_size: context_override_value(value, defaults, "zapret2_queue_range_size"),
nft_fakeip_mark: context_override_value(value, defaults, "nft_fakeip_mark"),
nft_outbound_mark: context_override_value(value, defaults, "nft_outbound_mark")
};
}
let mode = ARGV[0] || "";
if (mode == "stdin-first-line-field")
stdin_first_line_field(ARGV[1]);
else if (mode == "country-code-valid")
exit(country_code_valid(ARGV[1]) ? 0 : 1);
else if (mode == "enum-valid")
exit(enum_valid(ARGV[1], 2) ? 0 : 1);
else if (mode == "regex-valid")
exit(regex_valid(ARGV[1]) ? 0 : 1);
else if (mode == "combined-domain-valid")
exit(combined_domain_valid(ARGV[1]) ? 0 : 1);
else if (mode == "combined-domain-text-valid")
exit(combined_domain_text_valid(ARGV[1]) ? 0 : 1);
else if (mode == "community-service-valid")
exit(community_service_valid(ARGV[1], ARGV[2]) ? 0 : 1);
else if (mode == "ruleset-reference-valid")
exit(ruleset_reference_valid(ARGV[1], ARGV[2]) ? 0 : 1);
else if (mode == "plain-domain-ip-list-reference-valid")
exit(plain_domain_ip_list_reference_valid(ARGV[1]) ? 0 : 1);
else if (mode == "valid-outbound")
exit(valid_outbound() ? 0 : 1);
else if (mode == "valid-inbound")
exit(valid_inbound() ? 0 : 1);
else if (mode == "validate-outbound-detours")
validate_outbound_detours();
else if (mode == "validate-download-section")
validate_download_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "dhcp-has-https-dns-proxy-options")
dhcp_has_https_dns_proxy_options_exit(ARGV[1]);
else if (mode == "mwan3-has-enabled-interface")
exit(mwan3_has_enabled_interface() ? 0 : 1);
else if (mode == "mwan3-is-active")
exit(mwan3_is_active() ? 0 : 1);
else if (mode == "check-requirements")
check_runtime_requirements();
else if (mode == "validate-runtime")
validate_runtime_config(context_from_runtime());
// Both start-path validations in one process: the runtime context is expensive to
// assemble (it shells out for versions and installed-package markers) and each mode
// built its own copy. check_runtime_requirements() exits non-zero on failure, so
// reaching the second call already means the requirements passed.
else if (mode == "check-requirements-and-validate-runtime") {
check_runtime_requirements();
validate_runtime_config(context_from_runtime());
}
else if (mode == "validate-runtime-fixture") {
use_fixture_cursor(ARGV[1]);
validate_runtime_config(context_from_json(ARGV[2], context_from_runtime()));
}
else {
warn("Usage: config/validator.uc <operation> ...\n");
exit(1);
}