1641 lines
57 KiB
Ucode
1641 lines
57 KiB
Ucode
#!/usr/bin/env ucode
|
|
|
|
let fs = require("fs");
|
|
let common = require("core.common");
|
|
let core_ip = require("core.ip");
|
|
let uci_core = require("core.uci");
|
|
let runtime_constants = require("singbox.constants");
|
|
let runtime_country = require("singbox.country");
|
|
let runtime_dns = require("singbox.dns");
|
|
let runtime_route = require("singbox.route");
|
|
let runtime_rulesets = require("singbox.rulesets");
|
|
let runtime_servers = require("singbox.servers");
|
|
let runtime_subscription = require("singbox.subscription");
|
|
let runtime_url = require("core.url");
|
|
let runtime_urltest = require("singbox.urltest");
|
|
let source_rulesets = require("routing.rulesets");
|
|
let rule_config = require("config.rule");
|
|
let connections = require("config.connections");
|
|
let subscription_share_link = require("subscription.share_link");
|
|
let uci = null;
|
|
let fixture_uci_data = null;
|
|
let runtime_settings_cache = null;
|
|
let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER;
|
|
let runtime_supports_xhttp = true;
|
|
const TACHYON_RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
|
|
const TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE = getenv("TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE") || TACHYON_RUNTIME_STATE_DIR + "/deferred-sections";
|
|
|
|
let as_string = common.as_string;
|
|
let read_json_file = common.read_json_file;
|
|
let read_stdin = common.read_stdin;
|
|
let read_stdin_json = common.read_stdin_json;
|
|
let write_json = common.write_json;
|
|
let csv_to_json_array = common.csv_to_json_array;
|
|
let write_json_file = common.write_json_file;
|
|
let strip_internal_fields = common.strip_internal_fields;
|
|
let array_or_empty = common.array_or_empty;
|
|
let object_or_empty = common.object_or_empty;
|
|
let option = common.option;
|
|
let list_option = common.list_option;
|
|
let bool_option = common.bool_option;
|
|
let int_option = common.int_option;
|
|
let url_decode = runtime_url.decode;
|
|
let url_scheme = runtime_url.scheme;
|
|
let url_fragment = runtime_url.fragment;
|
|
let url_strip_fragment_value = runtime_url.strip_fragment;
|
|
let url_host = runtime_url.host;
|
|
let url_port = runtime_url.port;
|
|
let url_userinfo = runtime_url.userinfo;
|
|
let url_path = runtime_url.path;
|
|
let url_query_params = runtime_url.query_params;
|
|
|
|
const CONFIG_NAME = "tachyon";
|
|
|
|
function parse_deferred_sections(deferred_sections) {
|
|
let raw = trim(as_string(deferred_sections || getenv("TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS") || ""));
|
|
if (raw == "" && fs.stat(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE) != null) {
|
|
raw = trim(as_string(fs.readfile(TACHYON_SUBSCRIPTION_DEFERRED_SECTIONS_FILE)) || "");
|
|
}
|
|
let res = {};
|
|
if (raw != "") {
|
|
for (let s in split(raw, /[ \t\r\n]+/)) {
|
|
s = trim(s);
|
|
if (s != "")
|
|
res[s] = true;
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
|
|
// UCI stores values written as '<b 0x...>' strings as binary, which when
|
|
// serialized back to JSON appear as '<b 0x...>' — invalid for sing-box.
|
|
function uci_bin_to_hex(val) {
|
|
if (val == null || val == "") return "";
|
|
let s = as_string(val);
|
|
s = replace(s, /^<b\s*/i, "");
|
|
s = replace(s, /^0x/i, "");
|
|
s = replace(s, />$/, "");
|
|
s = replace(s, /\s+/g, "");
|
|
|
|
if (match(s, /^[0-9a-fA-F]+$/)) {
|
|
return lc(s);
|
|
}
|
|
|
|
let hex = "";
|
|
for (let i = 0; i < length(val); i++) {
|
|
let code = ord(val, i);
|
|
hex += sprintf("%02x", code);
|
|
}
|
|
return hex;
|
|
}
|
|
|
|
let generator_outbounds = require("singbox.generator_outbounds");
|
|
let generator_routes = require("singbox.generator_routes");
|
|
|
|
let ctx = {
|
|
outbounds: generator_outbounds,
|
|
routes: generator_routes,
|
|
runtime_supports_xhttp: true
|
|
};
|
|
|
|
let reserved_runtime_tag_set = null;
|
|
let assert_unique_outbound_tags = null;
|
|
|
|
let enabled_sections = null;
|
|
let enabled_servers = null;
|
|
let reserve_section_outbound_tags = null;
|
|
let add_outbound_for_section = null;
|
|
let add_service_route_rules = null;
|
|
let add_route_for_section = null;
|
|
let add_server_routes = null;
|
|
let ensure_custom_ruleset = null;
|
|
|
|
|
|
function parent_dir(path) {
|
|
path = as_string(path);
|
|
let slash = rindex(path, "/");
|
|
return slash <= 0 ? "" : substr(path, 0, slash);
|
|
}
|
|
|
|
function ensure_dir(path) {
|
|
path = as_string(path);
|
|
if (path == "" || path == "/")
|
|
return true;
|
|
if (fs.stat(path) != null)
|
|
return true;
|
|
|
|
let parent = parent_dir(path);
|
|
if (parent != "" && !ensure_dir(parent))
|
|
return false;
|
|
|
|
return fs.mkdir(path, 0755) || fs.stat(path) != null;
|
|
}
|
|
|
|
function ensure_parent_dir(path) {
|
|
return ensure_dir(parent_dir(path));
|
|
}
|
|
|
|
function atomic_write_json_file(path, value, indent) {
|
|
if (!ensure_parent_dir(path))
|
|
return false;
|
|
return write_json_file(path, value, indent != null ? indent : 2);
|
|
}
|
|
|
|
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 fixture_cursor(path) {
|
|
fixture_uci_data = object_or_empty(read_json_file(path));
|
|
connections.set_item_sections_from_data(fixture_uci_data);
|
|
return {
|
|
load: function(_config_name) {
|
|
return true;
|
|
},
|
|
get_all: function(_config_name, section_name) {
|
|
return fixture_get_section(section_name);
|
|
},
|
|
foreach: function(_config_name, type_name, callback) {
|
|
for (let section in fixture_section_list(type_name))
|
|
callback(section);
|
|
}
|
|
};
|
|
}
|
|
|
|
function use_fixture_cursor(path) {
|
|
uci = fixture_cursor(path);
|
|
runtime_settings_cache = null;
|
|
}
|
|
|
|
function runtime_uci_cursor() {
|
|
return {
|
|
load: function(package_name) {
|
|
return uci_core.load(package_name);
|
|
},
|
|
get_all: function(package_name, section_name) {
|
|
return uci_core.get_all(package_name, section_name);
|
|
},
|
|
foreach: function(package_name, type_name, callback) {
|
|
for (let section in uci_core.section_objects(package_name, type_name))
|
|
callback(section);
|
|
}
|
|
};
|
|
}
|
|
|
|
function uci_cursor() {
|
|
if (uci == null)
|
|
uci = runtime_uci_cursor();
|
|
return uci;
|
|
}
|
|
|
|
function runtime_generate_unsupported(reason) {
|
|
warn(reason, "\n");
|
|
exit(2);
|
|
}
|
|
|
|
function valid_section_name(name) {
|
|
return match(name, /^[A-Za-z0-9_]+$/);
|
|
}
|
|
|
|
function section_enabled(section) {
|
|
return bool_option(section, "enabled", true);
|
|
}
|
|
|
|
function runtime_settings() {
|
|
if (runtime_settings_cache == null)
|
|
runtime_settings_cache = object_or_empty(uci_cursor().get_all(CONFIG_NAME, "settings"));
|
|
return runtime_settings_cache;
|
|
}
|
|
|
|
function settings_update_interval() {
|
|
let settings = runtime_settings();
|
|
if (!bool_option(settings, "list_update_enabled", true))
|
|
return "";
|
|
|
|
let update_interval = option(settings, "update_interval", "1d");
|
|
return update_interval != "" ? update_interval : "1d";
|
|
}
|
|
|
|
function remote_ruleset_update_interval() {
|
|
let update_interval = settings_update_interval();
|
|
return update_interval != "" ? update_interval : runtime_constants.DISABLED_UPDATE_INTERVAL;
|
|
}
|
|
|
|
function internal_flag(value) {
|
|
return value === true || value == 1 || value == "1" || value == "true" || value == "yes";
|
|
}
|
|
|
|
function subscription_group_outbound(outbound) {
|
|
if (type(outbound) != "object")
|
|
return false;
|
|
let t = as_string(outbound.type);
|
|
return (t == "selector" || t == "urltest") && internal_flag(outbound.__tachyon_allow_group);
|
|
}
|
|
|
|
function subscription_urltest_group_outbound(outbound) {
|
|
if (type(outbound) != "object")
|
|
return false;
|
|
return as_string(outbound.type || "") == "urltest" && internal_flag(outbound.__tachyon_allow_group);
|
|
}
|
|
|
|
function subscription_outbound_tag(outbound) {
|
|
return type(outbound) == "object" ? as_string(outbound.tag || "") : "";
|
|
}
|
|
|
|
function subscription_visibility_refs(outbounds) {
|
|
let refs = {
|
|
urltest: {},
|
|
detour: {}
|
|
};
|
|
|
|
for (let outbound in array_or_empty(outbounds)) {
|
|
if (type(outbound) != "object")
|
|
continue;
|
|
|
|
if (subscription_urltest_group_outbound(outbound)) {
|
|
for (let tag_name in array_or_empty(outbound.outbounds)) {
|
|
tag_name = as_string(tag_name);
|
|
if (tag_name != "")
|
|
refs.urltest[tag_name] = true;
|
|
}
|
|
}
|
|
|
|
let detour = as_string(outbound.detour || "");
|
|
if (detour != "")
|
|
refs.detour[detour] = true;
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
function subscription_hidden_outbound(outbound, refs, hide_urltest_group_outbounds, hide_detour_outbounds) {
|
|
if (type(outbound) != "object")
|
|
return false;
|
|
|
|
let tag_name = subscription_outbound_tag(outbound);
|
|
let urltest_refs = object_or_empty(object_or_empty(refs).urltest);
|
|
let detour_refs = object_or_empty(object_or_empty(refs).detour);
|
|
let hidden_by_urltest = tag_name != "" && urltest_refs[tag_name];
|
|
let hidden_by_detour = tag_name != "" && detour_refs[tag_name];
|
|
|
|
if (hidden_by_urltest && hide_urltest_group_outbounds !== false)
|
|
return true;
|
|
if (hidden_by_detour && hide_detour_outbounds !== false)
|
|
return true;
|
|
return internal_flag(outbound.__tachyon_hidden) && !hidden_by_urltest && !hidden_by_detour;
|
|
}
|
|
|
|
function tag(base, postfix) {
|
|
return runtime_constants.tag(base, postfix);
|
|
}
|
|
|
|
function outbound_tag(section_name) {
|
|
return runtime_constants.outbound_tag(section_name);
|
|
}
|
|
|
|
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_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(settings, purpose) {
|
|
let enabled_option = download_via_proxy_option_for_purpose(purpose);
|
|
if (enabled_option == "" || !bool_option(settings, enabled_option, false))
|
|
return "";
|
|
|
|
let all_enabled = type(enabled_sections) == "function" ? enabled_sections() : [];
|
|
let valid_proxy_sections = {};
|
|
for (let s in all_enabled) {
|
|
let act = option(s, "action", "");
|
|
if (act != "bypass" && act != "block" && act != "dns" && act != "hosts" && act != "")
|
|
valid_proxy_sections[s[".name"]] = true;
|
|
}
|
|
|
|
let section_option = download_via_proxy_section_option_for_purpose(purpose);
|
|
let configured = section_option != "" ? option(settings, section_option, "") : "";
|
|
if (configured != "" && valid_proxy_sections[configured])
|
|
return configured;
|
|
|
|
let fallback_configured = option(settings, "download_lists_via_proxy_section", "");
|
|
if (fallback_configured != "" && valid_proxy_sections[fallback_configured])
|
|
return fallback_configured;
|
|
|
|
for (let s in all_enabled) {
|
|
if (valid_proxy_sections[s[".name"]])
|
|
return s[".name"];
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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 download_via_proxy_any_enabled(settings, sections) {
|
|
return download_via_proxy_enabled(settings, "lists") ||
|
|
download_via_proxy_enabled(settings, "components") ||
|
|
length(connections.subscription_download_targets(sections || [])) > 0;
|
|
}
|
|
|
|
function download_detour_tag(settings, purpose) {
|
|
let section_name = download_via_proxy_section(settings, purpose);
|
|
return section_name == "" ? "" : outbound_tag(section_name);
|
|
}
|
|
|
|
function ruleset_tag(section_name, name, kind) {
|
|
kind = as_string(kind);
|
|
return kind == ""
|
|
? section_name + "-" + name + "-ruleset"
|
|
: section_name + "-" + name + "-" + kind + "-ruleset";
|
|
}
|
|
|
|
function ruleset_registered(config, tag_name) {
|
|
for (let rule_set in array_or_empty(config.route && config.route.rule_set)) {
|
|
if (type(rule_set) == "object" && rule_set.tag == tag_name)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function clash_api_config(settings, service_address) {
|
|
let controller = as_string(service_address || "");
|
|
if (bool_option(settings, "enable_yacd", false) && bool_option(settings, "enable_yacd_wan_access", false))
|
|
controller = "0.0.0.0";
|
|
else if (controller == "")
|
|
controller = "0.0.0.0";
|
|
|
|
let result = {
|
|
external_controller: controller + ":9090"
|
|
};
|
|
if (bool_option(settings, "enable_yacd", false)) {
|
|
result.external_ui = "ui";
|
|
let secret = option(settings, "yacd_secret_key", "");
|
|
if (secret != "")
|
|
result.secret = secret;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function cli_bool(value) {
|
|
return value === true || value == "1" || value == "true" || value == "yes" || value == "on";
|
|
}
|
|
|
|
function tproxy_inbound_matcher() {
|
|
if (!core_ip.ipv6_supported())
|
|
return [ runtime_constants.TPROXY_INBOUND_TAG ];
|
|
return [ runtime_constants.TPROXY_INBOUND_TAG, runtime_constants.TPROXY_INBOUND6_TAG ];
|
|
}
|
|
|
|
let cached_sb_version = null;
|
|
|
|
function detect_sing_box_version() {
|
|
if (cached_sb_version != null)
|
|
return cached_sb_version;
|
|
|
|
let env_file = getenv("SB_VERSION_STATE_FILE");
|
|
if (env_file != null && env_file != "") {
|
|
let ver = trim(fs.readfile(env_file) || "");
|
|
if (ver != "") {
|
|
cached_sb_version = ver;
|
|
return ver;
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (let cmd in [ "sing-box version 2>/dev/null", "/usr/bin/sing-box version 2>/dev/null" ]) {
|
|
let pipe = fs.popen(cmd, "r");
|
|
if (pipe) {
|
|
let out = pipe.read("all");
|
|
pipe.close();
|
|
let m = match(out, /sing-box version ([^\s]+)/);
|
|
if (m) {
|
|
cached_sb_version = m[1];
|
|
try {
|
|
let cur_disk = trim(fs.readfile("/etc/tachyon/sing-box-version") || "");
|
|
if (cur_disk != cached_sb_version)
|
|
fs.writefile("/etc/tachyon/sing-box-version", cached_sb_version + "\n");
|
|
} catch (e) {}
|
|
return cached_sb_version;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
|
|
let sb_version_file = "/etc/tachyon/sing-box-version";
|
|
let sb_version_val = trim(fs.readfile(sb_version_file) || "");
|
|
if (sb_version_val == "") {
|
|
let sb_ui_cache = getenv("TACHYON_UI_SING_BOX_VERSION_CACHE_FILE") || "/var/run/tachyon/ui-state/sing-box-version";
|
|
sb_version_val = trim(fs.readfile(sb_ui_cache) || "");
|
|
}
|
|
cached_sb_version = sb_version_val;
|
|
return cached_sb_version;
|
|
}
|
|
|
|
function is_sb_1_14_plus_detected(sb_version_val) {
|
|
if (sb_version_val == null || sb_version_val == "")
|
|
sb_version_val = detect_sing_box_version();
|
|
if (sb_version_val != "") {
|
|
let m = match(sb_version_val, /^v?([0-9]+)\.([0-9]+)/);
|
|
if (m) {
|
|
let major = int(m[1]);
|
|
let minor = int(m[2]);
|
|
return major > 1 || (major == 1 && minor >= 14);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function is_sb_1_15_plus_detected(sb_version_val) {
|
|
if (sb_version_val == null || sb_version_val == "")
|
|
sb_version_val = detect_sing_box_version();
|
|
if (sb_version_val != "") {
|
|
let m = match(sb_version_val, /^v?([0-9]+)\.([0-9]+)/);
|
|
if (m) {
|
|
let major = int(m[1]);
|
|
let minor = int(m[2]);
|
|
return major > 1 || (major == 1 && minor >= 15);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function base_config(settings, service_address, runtime_context) {
|
|
runtime_context = object_or_empty(runtime_context);
|
|
let log_level = option(settings, "log_level", "warn");
|
|
let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60");
|
|
let turbo_cache = bool_option(settings, "dns_turbo_cache", false);
|
|
let cache_path = option(settings, "cache_path", "/tmp/sing-box/cache.db");
|
|
if (turbo_cache && (cache_path == "/tmp/sing-box/cache.db" || cache_path == ""))
|
|
cache_path = "/etc/sing-box/cache.db";
|
|
let cache_dir = replace(cache_path, /\/[^\/]+$/, "");
|
|
if (cache_dir != "" && cache_dir != cache_path) {
|
|
try { fs.mkdir(cache_dir, 0755); } catch(e) {}
|
|
}
|
|
let dns_config = runtime_dns.config(settings);
|
|
if (dns_config.unsupported)
|
|
runtime_generate_unsupported(dns_config.unsupported);
|
|
|
|
let dns_rules = [];
|
|
let dns_hosts_predefined = [];
|
|
|
|
let raw_dns_hosts = settings["dns_hosts"];
|
|
let dns_hosts_entries = [];
|
|
if (raw_dns_hosts != null && raw_dns_hosts != "") {
|
|
for (let line in split(as_string(raw_dns_hosts), "\n"))
|
|
push(dns_hosts_entries, line);
|
|
}
|
|
|
|
for (let host_entry in dns_hosts_entries) {
|
|
let entry_str = trim(host_entry);
|
|
if (entry_str == "" || substr(entry_str, 0, 1) == "#") continue;
|
|
let parts = split(entry_str, /[ \t]+/);
|
|
if (length(parts) >= 2) {
|
|
let p1 = parts[0];
|
|
let p2 = parts[1];
|
|
if (core_ip.valid_ip(p1)) {
|
|
let ip = p1;
|
|
let rr_type = index(ip, ":") != -1 ? "AAAA" : "A";
|
|
for (let i = 1; i < length(parts); i++) {
|
|
let d = parts[i];
|
|
if (d != "" && substr(d, 0, 1) != "#") {
|
|
push(dns_hosts_predefined, {
|
|
action: "predefined",
|
|
domain: [d],
|
|
answer: [d + ". 60 IN " + rr_type + " " + ip]
|
|
});
|
|
}
|
|
}
|
|
} else if (core_ip.valid_ip(p2)) {
|
|
let domain = p1;
|
|
let ip = p2;
|
|
let rr_type = index(ip, ":") != -1 ? "AAAA" : "A";
|
|
push(dns_hosts_predefined, {
|
|
action: "predefined",
|
|
domain: [domain],
|
|
answer: [domain + ". 60 IN " + rr_type + " " + ip]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let rule in dns_config.rules)
|
|
push(dns_rules, rule);
|
|
for (let rule in [
|
|
{ action: "reject", query_type: [ "HTTPS", "SVCB" ] },
|
|
{ action: "reject", domain_suffix: "use-application-dns.net" },
|
|
{
|
|
action: "route",
|
|
server: runtime_constants.FAKEIP_DNS_SERVER_TAG,
|
|
rewrite_ttl,
|
|
domain: [ runtime_constants.FAKEIP_TEST_DOMAIN, runtime_constants.CHECK_PROXY_IP_DOMAIN ]
|
|
}
|
|
])
|
|
push(dns_rules, rule);
|
|
|
|
let dns_servers = [];
|
|
for (let server in dns_config.servers)
|
|
push(dns_servers, server);
|
|
let fakeip_server = {
|
|
type: "fakeip",
|
|
tag: runtime_constants.FAKEIP_DNS_SERVER_TAG,
|
|
inet4_range: runtime_constants.FAKEIP_INET4_RANGE
|
|
};
|
|
let strategy = option(settings, "dns_strategy", "");
|
|
if (bool_option(settings, "ipv6_enabled", false) || strategy == "prefer_ipv6" || strategy == "ipv6_only")
|
|
fakeip_server.inet6_range = runtime_constants.FAKEIP_INET6_RANGE;
|
|
push(dns_servers, fakeip_server);
|
|
|
|
let inbounds = [
|
|
{ type: "tproxy", tag: runtime_constants.TPROXY_INBOUND_TAG, listen: runtime_constants.TPROXY_INBOUND_ADDRESS, listen_port: runtime_constants.TPROXY_INBOUND_PORT, tcp_fast_open: true, udp_fragment: true }
|
|
];
|
|
if (core_ip.ipv6_supported()) {
|
|
push(inbounds, { type: "tproxy", tag: runtime_constants.TPROXY_INBOUND6_TAG, listen: runtime_constants.TPROXY_INBOUND6_ADDRESS, listen_port: runtime_constants.TPROXY_INBOUND_PORT, tcp_fast_open: true, udp_fragment: true });
|
|
}
|
|
push(inbounds, { type: "direct", tag: runtime_constants.DNS_INBOUND_TAG, listen: runtime_constants.DNS_INBOUND_ADDRESS, listen_port: runtime_constants.DNS_INBOUND_PORT });
|
|
if (runtime_context.source_aware_dns) {
|
|
push(inbounds, {
|
|
type: "direct",
|
|
tag: runtime_constants.SOURCE_DNS_INBOUND_TAG,
|
|
listen: core_ip.ipv6_supported() ? runtime_constants.SOURCE_DNS_INBOUND_ADDRESS : "0.0.0.0",
|
|
listen_port: runtime_constants.SOURCE_DNS_INBOUND_PORT
|
|
});
|
|
}
|
|
for (let inbound in dns_config.inbounds)
|
|
push(inbounds, inbound);
|
|
|
|
let default_outbounds = [
|
|
{ type: "direct", tag: runtime_constants.DIRECT_OUTBOUND_TAG },
|
|
{ type: "direct", tag: runtime_constants.BYPASS_OUTBOUND_TAG }
|
|
];
|
|
|
|
runtime_context.dns_health_inbounds = dns_config.sniff_inbounds;
|
|
runtime_context.default_domain_resolver = runtime_dns.default_domain_resolver(settings);
|
|
|
|
let dns_section = {
|
|
servers: dns_servers,
|
|
rules: dns_rules,
|
|
final: runtime_constants.DNS_SERVER_TAG,
|
|
strategy: option(settings, "dns_strategy", "prefer_ipv4")
|
|
};
|
|
|
|
let sb_version_val = detect_sing_box_version();
|
|
let is_sb_1_14_plus = is_sb_1_14_plus_detected(sb_version_val);
|
|
let is_sb_1_15_plus = is_sb_1_15_plus_detected(sb_version_val);
|
|
ctx.is_sb_1_14_plus = function() { return is_sb_1_14_plus; };
|
|
ctx.is_sb_1_15_plus = function() { return is_sb_1_15_plus; };
|
|
|
|
let use_legacy_rdrc = !is_sb_1_14_plus;
|
|
|
|
let route_section = runtime_route.config(settings, runtime_context);
|
|
if (is_sb_1_14_plus)
|
|
route_section.default_http_client = "ruleset-http";
|
|
|
|
if (is_sb_1_14_plus) {
|
|
dns_section.optimistic = {
|
|
enabled: true,
|
|
timeout: "3d"
|
|
};
|
|
}
|
|
|
|
let cache_file_section = {
|
|
enabled: true,
|
|
path: cache_path,
|
|
store_fakeip: true
|
|
};
|
|
if (use_legacy_rdrc)
|
|
cache_file_section.store_rdrc = true;
|
|
else
|
|
cache_file_section.store_dns = true;
|
|
|
|
if (is_sb_1_15_plus) {
|
|
cache_file_section.buffer_size = "1MB";
|
|
cache_file_section.flush_interval = "10m";
|
|
}
|
|
|
|
let base_cfg = {
|
|
log: {
|
|
disabled: false,
|
|
level: log_level,
|
|
timestamp: false
|
|
},
|
|
dns: dns_section,
|
|
ntp: {},
|
|
certificate: {},
|
|
endpoints: [],
|
|
inbounds,
|
|
outbounds: default_outbounds,
|
|
route: route_section,
|
|
services: [],
|
|
experimental: {
|
|
cache_file: cache_file_section,
|
|
clash_api: clash_api_config(settings, service_address)
|
|
},
|
|
__dns_hosts_predefined: dns_hosts_predefined
|
|
};
|
|
|
|
if (is_sb_1_14_plus)
|
|
base_cfg.http_clients = [{ tag: "ruleset-http" }];
|
|
|
|
return base_cfg;
|
|
}
|
|
|
|
|
|
function mixed_proxy_enabled_action(action) {
|
|
return action == "connection" || action == "proxy" || action == "outbound" || action == "vpn" ||
|
|
action == "awg" || action == "byedpi" || action == "zapret" || action == "zapret2" ||
|
|
action == "wdtt" || action == "olcrtc";
|
|
}
|
|
|
|
function add_mixed_proxy_for_section(config, section, service_address) {
|
|
if (!bool_option(section, "mixed_proxy_enabled", false))
|
|
return;
|
|
if (ctx.deferred_sections && ctx.deferred_sections[section[".name"]])
|
|
return;
|
|
|
|
let action = option(section, "action", "");
|
|
if (!mixed_proxy_enabled_action(action))
|
|
runtime_generate_unsupported("mixed proxy inbound is not supported for action " + action);
|
|
|
|
let listen_port_value = option(section, "mixed_proxy_port", "");
|
|
if (match(listen_port_value, /^[0-9]+$/) == null)
|
|
runtime_generate_unsupported("mixed proxy port is invalid");
|
|
let listen_port = int(listen_port_value, 10);
|
|
if (listen_port < 1 || listen_port > 65535)
|
|
runtime_generate_unsupported("mixed proxy port is invalid");
|
|
|
|
let listen = as_string(service_address || "");
|
|
if (listen == "")
|
|
runtime_generate_unsupported("mixed proxy listen address is not set");
|
|
|
|
let inbound = {
|
|
type: "mixed",
|
|
tag: runtime_constants.inbound_tag(section[".name"] + "-mixed"),
|
|
listen,
|
|
listen_port
|
|
};
|
|
|
|
if (bool_option(section, "mixed_proxy_auth_enabled", false)) {
|
|
let username = option(section, "mixed_proxy_username", "");
|
|
let password = option(section, "mixed_proxy_password", "");
|
|
if (username == "" || password == "")
|
|
runtime_generate_unsupported("mixed proxy authentication is enabled but username or password is empty");
|
|
inbound.users = [{ username, password }];
|
|
}
|
|
push(config.inbounds, inbound);
|
|
if (type(config.route) == "object" && type(config.route.rules) == "array" &&
|
|
length(config.route.rules) > 0 && config.route.rules[0].action == "sniff" &&
|
|
type(config.route.rules[0].inbound) == "array")
|
|
push(config.route.rules[0].inbound, inbound.tag);
|
|
|
|
push(config.route.rules, {
|
|
action: "route",
|
|
inbound: inbound.tag,
|
|
ip_is_private: true,
|
|
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
|
|
});
|
|
let section_outbound = runtime_constants.outbound_tag(section[".name"]);
|
|
generator_routes.push_section_route_rule(config, {
|
|
action: "route",
|
|
inbound: inbound.tag,
|
|
outbound: section_outbound
|
|
}, section_outbound);
|
|
}
|
|
|
|
/*
|
|
* Direct Bypass provides an explicit, clean mixed (HTTP+SOCKS5) proxy port on LAN
|
|
* mapped directly to the direct outbound marked with OUTBOUND_MARK (0x08000000).
|
|
* Traffic received on this port bypasses all section routing rules, VPN tunnels, and
|
|
* DPI evasion engines, giving clients a guaranteed straight WAN route.
|
|
*/
|
|
function add_direct_bypass_proxy(config, settings, service_address) {
|
|
if (!bool_option(settings, "direct_bypass_enabled", false))
|
|
return;
|
|
|
|
let listen = as_string(service_address || "");
|
|
if (listen == "")
|
|
runtime_generate_unsupported("direct bypass listen address is not set");
|
|
|
|
let port_value = option(settings, "direct_bypass_port", as_string(runtime_constants.DIRECT_BYPASS_DEFAULT_PORT));
|
|
if (match(port_value, /^[0-9]+$/) == null)
|
|
runtime_generate_unsupported("direct bypass port is invalid");
|
|
let listen_port = int(port_value, 10);
|
|
if (listen_port < 1 || listen_port > 65535)
|
|
runtime_generate_unsupported("direct bypass port is invalid");
|
|
|
|
let inbound_tag = runtime_constants.DIRECT_BYPASS_INBOUND_TAG;
|
|
push(config.inbounds, {
|
|
type: "mixed",
|
|
tag: inbound_tag,
|
|
listen,
|
|
listen_port
|
|
});
|
|
push(config.outbounds, {
|
|
type: "direct",
|
|
tag: runtime_constants.DIRECT_BYPASS_OUTBOUND_TAG,
|
|
routing_mark: runtime_constants.OUTBOUND_MARK
|
|
});
|
|
if (type(config.route) == "object" && type(config.route.rules) == "array" &&
|
|
length(config.route.rules) > 0 && config.route.rules[0].action == "sniff" &&
|
|
type(config.route.rules[0].inbound) == "array")
|
|
push(config.route.rules[0].inbound, inbound_tag);
|
|
|
|
push(config.route.rules, {
|
|
action: "route",
|
|
inbound: inbound_tag,
|
|
ip_is_private: true,
|
|
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
|
|
});
|
|
push(config.route.rules, {
|
|
action: "route",
|
|
inbound: inbound_tag,
|
|
outbound: runtime_constants.DIRECT_BYPASS_OUTBOUND_TAG
|
|
});
|
|
}
|
|
|
|
function add_service_mixed_proxy_inbound(config, tag_name, listen_port, outbound) {
|
|
push(config.inbounds, {
|
|
type: "mixed",
|
|
tag: tag_name,
|
|
listen: runtime_constants.SERVICE_MIXED_INBOUND_ADDRESS,
|
|
listen_port
|
|
});
|
|
if (type(config.route) == "object" && type(config.route.rules) == "array" &&
|
|
length(config.route.rules) > 0 && config.route.rules[0].action == "sniff" &&
|
|
type(config.route.rules[0].inbound) == "array")
|
|
push(config.route.rules[0].inbound, tag_name);
|
|
|
|
push(config.route.rules, {
|
|
action: "route",
|
|
inbound: tag_name,
|
|
ip_is_private: true,
|
|
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
|
|
});
|
|
generator_routes.push_section_route_rule(config, {
|
|
action: "route",
|
|
inbound: tag_name,
|
|
outbound
|
|
}, outbound);
|
|
}
|
|
|
|
function service_mixed_proxy_inbound_tag_for_purpose(purpose) {
|
|
return as_string(purpose || "lists") == "components"
|
|
? runtime_constants.inbound_tag("service-components")
|
|
: runtime_constants.SERVICE_MIXED_INBOUND_TAG;
|
|
}
|
|
|
|
function service_mixed_proxy_port_for_purpose(purpose) {
|
|
return runtime_constants.SERVICE_MIXED_INBOUND_PORT +
|
|
(as_string(purpose || "lists") == "components" ? 1 : 0);
|
|
}
|
|
|
|
function add_global_download_service_mixed_proxy(config, settings, purpose) {
|
|
let outbound = download_detour_tag(settings, purpose);
|
|
if (outbound == "")
|
|
return;
|
|
|
|
add_service_mixed_proxy_inbound(
|
|
config,
|
|
service_mixed_proxy_inbound_tag_for_purpose(purpose),
|
|
service_mixed_proxy_port_for_purpose(purpose),
|
|
outbound
|
|
);
|
|
}
|
|
|
|
function add_subscription_download_service_mixed_proxies(config, sections) {
|
|
for (let target in connections.subscription_download_targets(sections)) {
|
|
if (ctx.deferred_sections && ctx.deferred_sections[target])
|
|
continue;
|
|
let port = connections.subscription_download_target_port(sections, target, runtime_constants.SERVICE_MIXED_INBOUND_PORT);
|
|
if (port <= 0)
|
|
runtime_generate_unsupported("subscription download proxy port could not be resolved");
|
|
|
|
add_service_mixed_proxy_inbound(
|
|
config,
|
|
runtime_constants.inbound_tag("service-subscription-" + target),
|
|
port,
|
|
outbound_tag(target)
|
|
);
|
|
}
|
|
}
|
|
|
|
function add_service_mixed_proxy(config, settings, sections) {
|
|
if (!download_via_proxy_any_enabled(settings, sections))
|
|
return;
|
|
|
|
add_global_download_service_mixed_proxy(config, settings, "lists");
|
|
add_global_download_service_mixed_proxy(config, settings, "components");
|
|
add_subscription_download_service_mixed_proxies(config, sections);
|
|
|
|
if (download_via_proxy_enabled(settings, "lists") && download_detour_tag(settings, "lists") == "")
|
|
runtime_generate_unsupported("download lists via proxy section is not set");
|
|
if (download_via_proxy_enabled(settings, "components") && download_detour_tag(settings, "components") == "")
|
|
runtime_generate_unsupported("download components via proxy section is not set");
|
|
}
|
|
|
|
function router_traffic_section(settings) {
|
|
if (!bool_option(settings, "route_router_traffic", false))
|
|
return "";
|
|
return option(settings, "route_router_traffic_section", "");
|
|
}
|
|
|
|
function add_router_traffic_redirect(config, settings) {
|
|
let section_name = router_traffic_section(settings);
|
|
if (section_name == "")
|
|
return;
|
|
|
|
push(config.inbounds, {
|
|
type: "redirect",
|
|
tag: runtime_constants.REDIRECT_INBOUND_TAG,
|
|
listen: runtime_constants.REDIRECT_INBOUND_ADDRESS,
|
|
listen_port: runtime_constants.REDIRECT_INBOUND_PORT
|
|
});
|
|
|
|
let sec_out = outbound_tag(section_name);
|
|
if (sec_out != "") {
|
|
let target_idx = 0;
|
|
if (type(config.route) == "object" && type(config.route.rules) == "array") {
|
|
for (let i = 0; i < length(config.route.rules); i++) {
|
|
let r = config.route.rules[i];
|
|
if (r.action == "sniff" || r.action == "hijack-dns")
|
|
target_idx = i + 1;
|
|
}
|
|
splice(config.route.rules, target_idx, 0, {
|
|
action: "route",
|
|
inbound: [ runtime_constants.REDIRECT_INBOUND_TAG ],
|
|
outbound: sec_out
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Content blocking (parental control domains) ─────────────────────────────
|
|
// Each enabled `config schedule` with blocked_domains generates:
|
|
// 1. DNS rules (on the dedicated dns-block-in inbound) that reject the
|
|
// blocked domains for the schedule's devices. IP-addressed devices get
|
|
// source_ip_cidr scoping; MAC-only devices are covered by the nftables
|
|
// DNS redirect (the DNS packets only reach dns-block-in when the
|
|
// nftables redirect rule fires, which already matches by MAC+time).
|
|
// 2. Route rules with action reject as a fallback when DNS cannot be
|
|
// redirected (always-on schedules only; time-gated schedules rely on
|
|
// the nftables redirect so their route rules would leak outside the
|
|
// active window).
|
|
// mode=allow (whitelist) inverts the DNS match: everything except the
|
|
// listed domains is rejected for the device.
|
|
|
|
function schedule_list_value(schedule, key) {
|
|
let values = list_option(schedule, key);
|
|
if (length(values) > 0)
|
|
return values;
|
|
let single = option(schedule, key, "");
|
|
return single == "" ? [] : [ single ];
|
|
}
|
|
|
|
function schedule_has_time_window(schedule) {
|
|
return trim(option(schedule, "start_time", "")) != "" ||
|
|
trim(option(schedule, "end_time", "")) != "";
|
|
}
|
|
|
|
function resolve_mac_to_ips(mac) {
|
|
return core_ip.resolve_mac_to_ips(mac);
|
|
}
|
|
|
|
function profile_source_ip_cidrs(profile) {
|
|
let result = [];
|
|
for (let raw in schedule_list_value(profile, "device_ip")) {
|
|
let device = trim(as_string(raw));
|
|
if (device == "")
|
|
continue;
|
|
let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
|
|
if (is_mac) {
|
|
for (let ip in resolve_mac_to_ips(device)) {
|
|
let cidr = core_ip.valid_ipv6(ip) ? ip + "/128" : ip + "/32";
|
|
if (index(result, cidr) < 0)
|
|
push(result, cidr);
|
|
}
|
|
continue;
|
|
}
|
|
if (core_ip.valid_ip(device)) {
|
|
let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32";
|
|
if (index(result, cidr) < 0) push(result, cidr);
|
|
} else if (core_ip.valid_ip_cidr(device)) {
|
|
if (index(result, device) < 0) push(result, device);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function schedule_source_ip_cidrs(schedule) {
|
|
let result = [];
|
|
for (let raw in schedule_list_value(schedule, "device_ip")) {
|
|
let device = trim(as_string(raw));
|
|
if (device == "")
|
|
continue;
|
|
let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
|
|
if (is_mac) {
|
|
for (let ip in resolve_mac_to_ips(device)) {
|
|
let cidr = core_ip.valid_ipv6(ip) ? ip + "/128" : ip + "/32";
|
|
if (index(result, cidr) < 0)
|
|
push(result, cidr);
|
|
}
|
|
continue;
|
|
}
|
|
if (core_ip.valid_ip(device)) {
|
|
let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32";
|
|
if (index(result, cidr) < 0) push(result, cidr);
|
|
} else if (core_ip.valid_ip_cidr(device)) {
|
|
if (index(result, device) < 0) push(result, device);
|
|
}
|
|
}
|
|
for (let p_name in schedule_list_value(schedule, "profile")) {
|
|
let prof = ctx.uci_cursor().get_all(CONFIG_NAME, p_name);
|
|
if (prof != null && section_enabled(prof)) {
|
|
for (let cidr in profile_source_ip_cidrs(prof)) {
|
|
if (index(result, cidr) < 0)
|
|
push(result, cidr);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function schedule_domain_matcher_rule(rule, schedule) {
|
|
let raw = schedule_list_value(schedule, "blocked_domains");
|
|
let plain = [];
|
|
let keyword = [];
|
|
let regex = [];
|
|
let full = [];
|
|
for (let domain in raw) {
|
|
let value = trim(as_string(domain));
|
|
if (value == "")
|
|
continue;
|
|
if (substr(value, 0, 6) == "full:")
|
|
push(full, trim(substr(value, 6)));
|
|
else if (substr(value, 0, 8) == "keyword:")
|
|
push(keyword, trim(substr(value, 8)));
|
|
else if (substr(value, 0, 6) == "regex:")
|
|
push(regex, trim(substr(value, 6)));
|
|
else if (substr(value, 0, 1) == ".")
|
|
push(plain, substr(value, 1));
|
|
else if (index(value, "*") >= 0)
|
|
push(plain, replace(value, "*.", ""));
|
|
else
|
|
push(plain, value);
|
|
}
|
|
if (length(full) > 0)
|
|
rule.domain = full;
|
|
if (length(plain) > 0)
|
|
rule.domain_suffix = plain;
|
|
if (length(keyword) > 0)
|
|
rule.domain_keyword = keyword;
|
|
if (length(regex) > 0)
|
|
rule.domain_regex = regex;
|
|
return rule;
|
|
}
|
|
|
|
function schedule_has_mac_devices(schedule) {
|
|
for (let raw in schedule_list_value(schedule, "device_ip")) {
|
|
let device = trim(as_string(raw));
|
|
if (match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null)
|
|
return true;
|
|
}
|
|
for (let p_name in schedule_list_value(schedule, "profile")) {
|
|
let prof = ctx.uci_cursor().get_all(CONFIG_NAME, p_name);
|
|
if (prof != null && section_enabled(prof)) {
|
|
if (schedule_has_mac_devices(prof))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function schedule_blocked_domains(schedule) {
|
|
let result = [];
|
|
for (let domain in schedule_list_value(schedule, "blocked_domains")) {
|
|
let value = trim(as_string(domain));
|
|
if (value != "")
|
|
push(result, value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function enabled_content_block_schedules() {
|
|
let result = [];
|
|
ctx.uci_cursor().foreach(CONFIG_NAME, "schedule", function(schedule) {
|
|
if (section_enabled(schedule) && length(schedule_blocked_domains(schedule)) > 0) {
|
|
push(result, schedule);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function enabled_content_block_profiles() {
|
|
let result = [];
|
|
ctx.uci_cursor().foreach(CONFIG_NAME, "profile", function(profile) {
|
|
if (section_enabled(profile) && length(schedule_blocked_domains(profile)) > 0)
|
|
push(result, profile);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function enabled_safesearch_profiles() {
|
|
let result = [];
|
|
ctx.uci_cursor().foreach(CONFIG_NAME, "profile", function(profile) {
|
|
if (section_enabled(profile) && bool_option(profile, "safe_search", false))
|
|
push(result, profile);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function add_safesearch_dns_rules(target, profiles) {
|
|
if (length(profiles) == 0)
|
|
return;
|
|
|
|
let rules_arr = (type(target) == "object" && type(target.dns) == "object" && type(target.dns.rules) == "array")
|
|
? target.dns.rules
|
|
: target;
|
|
|
|
let all_sources = [];
|
|
for (let profile in profiles) {
|
|
for (let cidr in profile_source_ip_cidrs(profile)) {
|
|
if (index(all_sources, cidr) < 0)
|
|
push(all_sources, cidr);
|
|
}
|
|
}
|
|
|
|
let search_engines = [
|
|
{
|
|
domains: [ "google.com", "www.google.com", "google.ru", "www.google.ru" ],
|
|
ip: "216.239.38.120"
|
|
},
|
|
{
|
|
domains: [ "yandex.ru", "www.yandex.ru", "ya.ru", "www.ya.ru", "yandex.com", "www.yandex.com" ],
|
|
ip: "213.180.193.56"
|
|
},
|
|
{
|
|
domains: [ "bing.com", "www.bing.com" ],
|
|
ip: "204.79.197.220"
|
|
},
|
|
{
|
|
domains: [ "duckduckgo.com", "www.duckduckgo.com", "safe.duckduckgo.com" ],
|
|
ip: "52.142.124.215"
|
|
},
|
|
{
|
|
domains: [ "youtube.com", "www.youtube.com", "m.youtube.com", "youtubei.googleapis.com" ],
|
|
ip: "216.239.38.120"
|
|
}
|
|
];
|
|
|
|
for (let engine in search_engines) {
|
|
for (let domain in engine.domains) {
|
|
let rule = {
|
|
action: "predefined",
|
|
domain: [ domain ],
|
|
answer: [ domain + ". 60 IN A " + engine.ip ]
|
|
};
|
|
if (length(all_sources) > 0)
|
|
rule.source_ip_cidr = all_sources;
|
|
push(rules_arr, rule);
|
|
}
|
|
}
|
|
}
|
|
|
|
function add_content_block_dns_inbound(config) {
|
|
push(config.inbounds, {
|
|
type: "direct",
|
|
tag: runtime_constants.DNS_BLOCK_INBOUND_TAG,
|
|
listen: core_ip.ipv6_supported() ? runtime_constants.DNS_BLOCK_INBOUND_ADDRESS : "0.0.0.0",
|
|
listen_port: runtime_constants.DNS_BLOCK_INBOUND_PORT
|
|
});
|
|
}
|
|
|
|
function add_content_block_dns_rules(target, schedules) {
|
|
let rules_arr = (type(target) == "object" && type(target.dns) == "object" && type(target.dns.rules) == "array")
|
|
? target.dns.rules
|
|
: target;
|
|
let added = false;
|
|
for (let schedule in schedules) {
|
|
let mode = option(schedule, "mode", "block");
|
|
let sources = schedule_source_ip_cidrs(schedule);
|
|
let has_mac = schedule_has_mac_devices(schedule);
|
|
for (let raw_domain in schedule_blocked_domains(schedule)) {
|
|
let domain_value = trim(as_string(raw_domain));
|
|
if (domain_value == "")
|
|
continue;
|
|
let rule = {
|
|
action: "reject",
|
|
inbound: [ runtime_constants.DNS_BLOCK_INBOUND_TAG ]
|
|
};
|
|
let matchers = {};
|
|
schedule_domain_matcher_rule(matchers, { blocked_domains: [ domain_value ] });
|
|
for (let key in [ "domain", "domain_suffix", "domain_keyword", "domain_regex" ]) {
|
|
if (matchers[key] != null)
|
|
rule[key] = matchers[key];
|
|
}
|
|
if (length(sources) > 0 && !has_mac)
|
|
rule.source_ip_cidr = sources;
|
|
if (mode == "allow")
|
|
rule.invert = true;
|
|
push(rules_arr, rule);
|
|
added = true;
|
|
}
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function add_content_block_route_rules(target, schedules) {
|
|
let rules_arr = (type(target) == "object" && type(target.route) == "object" && type(target.route.rules) == "array")
|
|
? target.route.rules
|
|
: target;
|
|
for (let schedule in schedules) {
|
|
if (schedule_has_time_window(schedule))
|
|
continue;
|
|
let mode = option(schedule, "mode", "block");
|
|
let sources = schedule_source_ip_cidrs(schedule);
|
|
if (length(sources) == 0)
|
|
continue;
|
|
let rule = {
|
|
action: "reject",
|
|
inbound: tproxy_inbound_matcher()
|
|
};
|
|
schedule_domain_matcher_rule(rule, schedule);
|
|
rule.source_ip_cidr = sources;
|
|
if (mode == "allow")
|
|
rule.invert = true;
|
|
push(rules_arr, rule);
|
|
}
|
|
}
|
|
|
|
function add_content_blocking(config) {
|
|
let schedules = enabled_content_block_schedules();
|
|
let profiles = enabled_content_block_profiles();
|
|
let safesearch_profiles = enabled_safesearch_profiles();
|
|
|
|
if (length(schedules) == 0 && length(profiles) == 0 && length(safesearch_profiles) == 0)
|
|
return;
|
|
|
|
add_content_block_dns_inbound(config);
|
|
unshift(config.route.rules, {
|
|
action: "hijack-dns",
|
|
inbound: [ runtime_constants.DNS_BLOCK_INBOUND_TAG ]
|
|
});
|
|
|
|
let new_dns_rules = [];
|
|
add_content_block_dns_rules(new_dns_rules, schedules);
|
|
add_content_block_dns_rules(new_dns_rules, profiles);
|
|
add_safesearch_dns_rules(new_dns_rules, safesearch_profiles);
|
|
|
|
// Place content blocking DNS rules at the front of config.dns.rules so
|
|
// queries on dns-block-in are rejected before any section FakeIP rule matches!
|
|
for (let i = length(new_dns_rules) - 1; i >= 0; i--)
|
|
unshift(config.dns.rules, new_dns_rules[i]);
|
|
|
|
let new_route_rules = [];
|
|
add_content_block_route_rules(new_route_rules, schedules);
|
|
add_content_block_route_rules(new_route_rules, profiles);
|
|
|
|
// Insert content blocking route rules right after hijack-dns (index 1)
|
|
// so sniffed domains from blocked clients are rejected before any section proxy route matches!
|
|
for (let i = length(new_route_rules) - 1; i >= 0; i--)
|
|
splice(config.route.rules, 1, 0, new_route_rules[i]);
|
|
}
|
|
|
|
function single_or_array(values) {
|
|
if (type(values) != "array")
|
|
return values;
|
|
if (length(values) == 1)
|
|
return values[0];
|
|
return values;
|
|
}
|
|
|
|
function get_excluded_client_cidrs(settings) {
|
|
let result = [];
|
|
let seen = {};
|
|
let add_cidr = function(ip) {
|
|
ip = trim(as_string(ip));
|
|
if (ip == "") return;
|
|
let is_mac = match(ip, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
|
|
if (is_mac) {
|
|
for (let res_ip in resolve_mac_to_ips(ip)) {
|
|
let cidr = core_ip.valid_ipv6(res_ip) ? res_ip + "/128" : res_ip + "/32";
|
|
if (!seen[cidr]) {
|
|
seen[cidr] = true;
|
|
push(result, cidr);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (core_ip.valid_ip(ip)) {
|
|
let cidr = core_ip.valid_ipv6(ip) ? ip + "/128" : ip + "/32";
|
|
if (!seen[cidr]) {
|
|
seen[cidr] = true;
|
|
push(result, cidr);
|
|
}
|
|
} else if (core_ip.valid_ip_cidr(ip)) {
|
|
if (!seen[ip]) {
|
|
seen[ip] = true;
|
|
push(result, ip);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (settings) {
|
|
for (let item in list_option(settings, "excluded_clients"))
|
|
add_cidr(item);
|
|
for (let item in list_option(settings, "excluded_ips"))
|
|
add_cidr(item);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function source_aware_dns_sources(sections, settings) {
|
|
let sources = [];
|
|
let seen = {};
|
|
let add_source = function(value) {
|
|
value = as_string(value);
|
|
if (value == "" || seen[value])
|
|
return;
|
|
seen[value] = true;
|
|
push(sources, value);
|
|
};
|
|
|
|
if (settings) {
|
|
for (let cidr in get_excluded_client_cidrs(settings))
|
|
add_source(cidr);
|
|
}
|
|
|
|
for (let section in sections) {
|
|
let action = option(section, "action", "");
|
|
let has_dns_matchers = connections.has_dns_matchers(section);
|
|
|
|
if (has_dns_matchers) {
|
|
for (let ip in core_ip.normalize_to_cidrs(list_option(section, "source_ip_cidr")))
|
|
add_source(ip);
|
|
}
|
|
|
|
if (action == "bypass") {
|
|
for (let ip in core_ip.normalize_to_cidrs(list_option(section, "fully_routed_ips")))
|
|
add_source(ip);
|
|
for (let ip in core_ip.normalize_to_cidrs(list_option(section, "source_ip_cidr")))
|
|
add_source(ip);
|
|
}
|
|
|
|
if (action == "dns") {
|
|
for (let ip in core_ip.normalize_to_cidrs(list_option(section, "fully_routed_ips")))
|
|
add_source(ip);
|
|
for (let ip in core_ip.normalize_to_cidrs(list_option(section, "source_ip_cidr")))
|
|
add_source(ip);
|
|
}
|
|
}
|
|
|
|
return sources;
|
|
}
|
|
|
|
function add_source_aware_dns_support(config, source_aware_dns) {
|
|
if (length(source_aware_dns) == 0)
|
|
return;
|
|
|
|
push(config.dns.servers, {
|
|
type: "udp",
|
|
tag: runtime_constants.DNSMASQ_DNS_SERVER_TAG,
|
|
server: "127.0.0.1",
|
|
server_port: 53
|
|
});
|
|
}
|
|
|
|
function add_source_aware_dns_fallback(config, source_aware_dns) {
|
|
if (length(source_aware_dns) == 0)
|
|
return;
|
|
|
|
let settings = runtime_settings_cache || {};
|
|
let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60");
|
|
|
|
let rule = {
|
|
action: "route",
|
|
server: runtime_constants.DNSMASQ_DNS_SERVER_TAG,
|
|
inbound: [ runtime_constants.SOURCE_DNS_INBOUND_TAG ],
|
|
source_ip_cidr: single_or_array(source_aware_dns),
|
|
rewrite_ttl
|
|
};
|
|
push(config.dns.rules, rule);
|
|
}
|
|
|
|
function add_excluded_clients_dns_rule(config, settings) {
|
|
let excluded_cidrs = get_excluded_client_cidrs(settings);
|
|
if (length(excluded_cidrs) == 0)
|
|
return;
|
|
|
|
let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60");
|
|
unshift(config.dns.rules, {
|
|
action: "route",
|
|
server: runtime_constants.DNSMASQ_DNS_SERVER_TAG,
|
|
inbound: [ runtime_constants.SOURCE_DNS_INBOUND_TAG ],
|
|
source_ip_cidr: single_or_array(excluded_cidrs),
|
|
rewrite_ttl
|
|
});
|
|
}
|
|
|
|
function add_excluded_clients_route_rule(config, settings) {
|
|
let excluded_cidrs = get_excluded_client_cidrs(settings);
|
|
if (length(excluded_cidrs) == 0)
|
|
return;
|
|
|
|
splice(config.route.rules, 1, 0, {
|
|
action: "route",
|
|
outbound: runtime_constants.BYPASS_OUTBOUND_TAG,
|
|
source_ip_cidr: single_or_array(excluded_cidrs)
|
|
});
|
|
}
|
|
|
|
function generate_config(output_path, service_address, mwan3_active, supports_xhttp, deferred_sections) {
|
|
ctx.deferred_sections = parse_deferred_sections(deferred_sections);
|
|
ctx.runtime_ruleset_folder = runtime_ruleset_folder;
|
|
runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == ""
|
|
? true
|
|
: cli_bool(supports_xhttp);
|
|
ctx.runtime_supports_xhttp = runtime_supports_xhttp;
|
|
let cursor = uci_cursor();
|
|
cursor.load(CONFIG_NAME);
|
|
runtime_settings_cache = object_or_empty(cursor.get_all(CONFIG_NAME, "settings"));
|
|
let settings = runtime_settings_cache;
|
|
|
|
let sections = enabled_sections();
|
|
let servers = enabled_servers();
|
|
if (length(sections) == 0 && length(servers) == 0)
|
|
runtime_generate_unsupported("no enabled sections");
|
|
|
|
let source_aware_dns = source_aware_dns_sources(sections, settings);
|
|
let config = base_config(settings, service_address, {
|
|
mwan3_active: cli_bool(mwan3_active),
|
|
source_aware_dns: length(source_aware_dns) > 0
|
|
});
|
|
add_source_aware_dns_support(config, source_aware_dns);
|
|
let taken = reserved_runtime_tag_set(config.outbounds);
|
|
reserve_section_outbound_tags(sections, taken);
|
|
for (let server in servers)
|
|
runtime_servers.add_server(config, server);
|
|
for (let section in sections)
|
|
add_outbound_for_section(config, section, taken, sections);
|
|
add_service_route_rules(config, sections);
|
|
|
|
// Add block and bypass sections before proxy sections so exceptions take precedence
|
|
for (let section in sections) {
|
|
let act = option(section, "action", "");
|
|
if (act == "block" || act == "bypass")
|
|
add_route_for_section(config, section);
|
|
}
|
|
for (let section in sections) {
|
|
let act = option(section, "action", "");
|
|
if (act != "block" && act != "bypass")
|
|
add_route_for_section(config, section);
|
|
}
|
|
|
|
add_server_routes(config, servers, sections);
|
|
add_source_aware_dns_fallback(config, source_aware_dns);
|
|
add_excluded_clients_dns_rule(config, settings);
|
|
add_excluded_clients_route_rule(config, settings);
|
|
|
|
// Append dns_hosts predefined rules AFTER section DNS rules so that
|
|
// FakeIP/section-level DNS routing takes precedence over hardcoded IPs
|
|
// from dns_hosts or hosts cache. This prevents third-party hosts entries
|
|
// (e.g. from "Unlock AI" projects) from short-circuiting FakeIP for
|
|
// domains that are already routed through proxy via section rules.
|
|
if (type(config.__dns_hosts_predefined) == "array") {
|
|
for (let rule in config.__dns_hosts_predefined)
|
|
push(config.dns.rules, rule);
|
|
}
|
|
|
|
add_direct_bypass_proxy(config, settings, service_address);
|
|
add_service_mixed_proxy(config, settings, sections);
|
|
for (let section in sections)
|
|
add_mixed_proxy_for_section(config, section, service_address);
|
|
add_router_traffic_redirect(config, settings);
|
|
|
|
add_content_blocking(config);
|
|
|
|
if (type(config.http_clients) == "array" && length(config.http_clients) > 0) {
|
|
let detour = download_detour_tag(settings, "lists");
|
|
if (detour != "" && generator_routes.is_valid_detour(config, detour))
|
|
config.http_clients[0].detour = detour;
|
|
}
|
|
|
|
assert_unique_outbound_tags(config);
|
|
strip_internal_fields(config);
|
|
if (!atomic_write_json_file(output_path, config)) {
|
|
warn("failed to write ", output_path, "\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active, supports_xhttp, deferred_sections) {
|
|
use_fixture_cursor(fixture_path);
|
|
runtime_subscription.set_section_cache_dir(output_path + ".section-cache");
|
|
runtime_ruleset_folder = output_path + ".rulesets";
|
|
generate_config(output_path, service_address, mwan3_active, supports_xhttp, deferred_sections);
|
|
}
|
|
|
|
function stdin_length() {
|
|
let value = read_stdin_json();
|
|
if (type(value) == "array" || type(value) == "object")
|
|
print(length(value), "\n");
|
|
else
|
|
print("0\n");
|
|
}
|
|
|
|
function stdin_contains(needle) {
|
|
return index(read_stdin(), as_string(needle)) >= 0;
|
|
}
|
|
|
|
function stdin_regex_matches(pattern) {
|
|
pattern = as_string(pattern);
|
|
if (pattern == "")
|
|
return false;
|
|
|
|
try {
|
|
return match(read_stdin(), regexp(pattern)) != null;
|
|
}
|
|
catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function ip_addr_first_inet4() {
|
|
for (let line in split(read_stdin(), "\n")) {
|
|
let fields = split(trim(as_string(line)), /[ \t]+/);
|
|
if (length(fields) < 2 || fields[0] != "inet")
|
|
continue;
|
|
|
|
let slash = index(fields[1], "/");
|
|
print(slash >= 0 ? substr(fields[1], 0, slash) : fields[1], "\n");
|
|
return;
|
|
}
|
|
}
|
|
|
|
function stdin_first_dns_a_address() {
|
|
for (let line in split(read_stdin(), "\n")) {
|
|
line = as_string(line);
|
|
if (match(line, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) != null) {
|
|
print(line, "\n");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function stdin_first_dns_aaaa_address() {
|
|
for (let line in split(read_stdin(), "\n")) {
|
|
line = as_string(line);
|
|
if (match(line, /^[0-9A-Fa-f:]+$/) != null) {
|
|
print(line, "\n");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function stdin_first_nslookup_address() {
|
|
for (let line in split(read_stdin(), "\n")) {
|
|
line = as_string(line);
|
|
if (match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) == null &&
|
|
match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9A-Fa-f:]+$/) == null)
|
|
continue;
|
|
|
|
let fields = split(trim(line), /[ \t]+/);
|
|
if (length(fields) > 0)
|
|
print(fields[length(fields) - 1], "\n");
|
|
return;
|
|
}
|
|
}
|
|
|
|
function stdin_first_field() {
|
|
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]+/);
|
|
|
|
if (length(fields) > 0 && fields[0] != "")
|
|
print(fields[0], "\n");
|
|
}
|
|
|
|
function array_append_string(value) {
|
|
let result = array_or_empty(read_stdin_json());
|
|
push(result, as_string(value));
|
|
write_json(result);
|
|
}
|
|
|
|
function normalized_country_list() {
|
|
write_json(runtime_urltest.normalized_country_list(read_stdin_json()));
|
|
}
|
|
|
|
function urltest_filter(mode, tags_path, names_path, countries_path, names_filter_path, regex_tags_path, countries_filter_path) {
|
|
write_json(runtime_urltest.filter_array(
|
|
mode,
|
|
read_json_file(tags_path),
|
|
read_json_file(names_path),
|
|
read_json_file(countries_path),
|
|
read_json_file(names_filter_path),
|
|
read_json_file(regex_tags_path),
|
|
read_json_file(countries_filter_path)
|
|
));
|
|
}
|
|
|
|
function object_nonempty_stdin() {
|
|
let value = read_stdin_json();
|
|
return (type(value) == "array" || type(value) == "object") && length(value) > 0;
|
|
}
|
|
|
|
ctx.uci_cursor = uci_cursor;
|
|
ctx.runtime_settings = runtime_settings;
|
|
ctx.runtime_generate_unsupported = runtime_generate_unsupported;
|
|
ctx.uci_bin_to_hex = uci_bin_to_hex;
|
|
ctx.download_detour_tag = download_detour_tag;
|
|
ctx.atomic_write_json_file = atomic_write_json_file;
|
|
ctx.is_sb_1_14_plus = is_sb_1_14_plus_detected;
|
|
ctx.is_sb_1_15_plus = is_sb_1_15_plus_detected;
|
|
|
|
generator_outbounds.init(ctx);
|
|
generator_routes.init(ctx);
|
|
|
|
reserved_runtime_tag_set = generator_outbounds.reserved_runtime_tag_set;
|
|
assert_unique_outbound_tags = generator_outbounds.assert_unique_outbound_tags;
|
|
|
|
enabled_sections = generator_routes.enabled_sections;
|
|
enabled_servers = generator_routes.enabled_servers;
|
|
reserve_section_outbound_tags = generator_routes.reserve_section_outbound_tags;
|
|
add_outbound_for_section = generator_routes.add_outbound_for_section;
|
|
add_service_route_rules = generator_routes.add_service_route_rules;
|
|
add_route_for_section = generator_routes.add_route_for_section;
|
|
add_server_routes = generator_routes.add_server_routes;
|
|
ensure_custom_ruleset = generator_routes.ensure_custom_ruleset;
|
|
|
|
let mode = ARGV[0] || "";
|
|
|
|
if (mode == "generate-config")
|
|
generate_config(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]);
|
|
else if (mode == "generate-config-fixture")
|
|
generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]);
|
|
else if (mode == "stdin-length")
|
|
stdin_length();
|
|
else if (mode == "stdin-contains")
|
|
exit(stdin_contains(ARGV[1]) ? 0 : 1);
|
|
else if (mode == "stdin-regex-matches")
|
|
exit(stdin_regex_matches(ARGV[1]) ? 0 : 1);
|
|
else if (mode == "csv-to-json-array")
|
|
csv_to_json_array(ARGV[1]);
|
|
else if (mode == "ip-addr-first-inet4")
|
|
ip_addr_first_inet4();
|
|
else if (mode == "stdin-first-dns-a-address")
|
|
stdin_first_dns_a_address();
|
|
else if (mode == "stdin-first-dns-aaaa-address")
|
|
stdin_first_dns_aaaa_address();
|
|
else if (mode == "stdin-first-nslookup-address")
|
|
stdin_first_nslookup_address();
|
|
else if (mode == "stdin-first-field")
|
|
stdin_first_field();
|
|
else if (mode == "array-append-string")
|
|
array_append_string(ARGV[1]);
|
|
else if (mode == "normalized-country-list")
|
|
normalized_country_list();
|
|
else if (mode == "urltest-filter")
|
|
urltest_filter(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]);
|
|
else if (mode == "object-nonempty")
|
|
exit(object_nonempty_stdin() ? 0 : 1);
|
|
else {
|
|
warn("Usage: singbox/generator.uc <operation> ...\n");
|
|
exit(1);
|
|
}
|