#!/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 remote_plain_list_reference(value) { value = lc(as_string(value)); let query = index(value, "?"); if (query >= 0) value = substr(value, 0, query); let fragment = index(value, "#"); if (fragment >= 0) value = substr(value, 0, fragment); return string_ends_with(value, ".lst") || string_ends_with(value, ".txt"); } 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") || absolute_reference_with_extension(reference, ".lst", ".txt"); } function plain_domain_ip_list_reference_valid(reference) { reference = as_string(reference); return reference == "" || remote_reference(reference) || absolute_reference_with_extension(reference, ".lst", ".txt"); } 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" ]); } let parse_number = common.parse_number; 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; } let section_name = common.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", "fptn" ], 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", "fptn" ], 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 (action == "fptn") { let fptn_validator = require("providers.fptn.validator"); let vres = fptn_validator.validate_section(section); if (!vres.valid) fail_validation("FPTN rule '" + name + "': " + vres.error + ". Aborted."); } let provider_labels = { zapret: "Zapret", zapret2: "Zapret2", byedpi: "ByeDPI", wdtt: "WDTT", olcrtc: "OlcRTC", fptn: "FPTN" }; if (provider_labels[action] != null) { if (!connections.section_has_routing_matchers(section) && !bool_option(section, "match_all", false)) fail_validation(provider_labels[action] + " rule '" + name + "' has no routing conditions. Add domains, community lists, IP/subnet/port/protocol matchers or enable Match All explicitly. Aborted."); } 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 == "block") fail_validation("Rule '" + name + "' has routed DNS enabled but action '" + action + "' does not support routed DNS. 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")), fptn_installed: file_executable("/usr/bin/fptn-client-cli") || file_executable("/usr/bin/fptn-client"), fptn_bin: file_executable("/usr/bin/fptn-client-cli") ? "/usr/bin/fptn-client-cli" : "/usr/bin/fptn-client", fptn_state_dir: "/var/run/tachyon/fptn", 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", "fptn" ], 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." ); check_provider_requirement( "fptn", "FPTN", ctx.fptn_bin, [ ctx.fptn_state_dir ], "FPTN provider is not available at " + ctx.fptn_bin + ". Rules with action 'fptn' will be skipped until the fptn package is installed.", "Failed to prepare the Tachyon FPTN state directory in " + ctx.fptn_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 ...\n"); exit(1); }