mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 20:12:51 +08:00
1307 lines
44 KiB
Ucode
Executable File
1307 lines
44 KiB
Ucode
Executable File
#!/usr/bin/ucode
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Copyright (C) 2023-2025 ImmortalWrt.org
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import { access, readfile, writefile } from 'fs';
|
|
import { isnan } from 'math';
|
|
import { connect } from 'ubus';
|
|
import { cursor } from 'uci';
|
|
|
|
import {
|
|
isEmpty, parseURL, strToBool, strToInt, strToTime, strToMs,
|
|
removeBlankAttrs, validation, buildTLSObject, buildTransportObject,
|
|
HP_DIR, RUN_DIR
|
|
} from 'homeproxy';
|
|
|
|
const ubus = connect();
|
|
|
|
/* const features = ubus.call('luci.homeproxy', 'singbox_get_features') || {}; */
|
|
|
|
/* UCI config start */
|
|
const uci = cursor();
|
|
|
|
const uciconfig = 'homeproxy';
|
|
uci.load(uciconfig);
|
|
|
|
const uciinfra = 'infra',
|
|
ucimain = 'config',
|
|
ucicontrol = 'control';
|
|
|
|
const ucidnssetting = 'dns',
|
|
ucidnsserver = 'dns_server',
|
|
ucidnsrule = 'dns_rule';
|
|
|
|
const uciroutingsetting = 'routing',
|
|
uciroutingnode = 'routing_node',
|
|
uciroutingrule = 'routing_rule';
|
|
|
|
const ucinode = 'node';
|
|
const uciruleset = 'ruleset';
|
|
|
|
const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainland_china';
|
|
|
|
/*
|
|
* Single source of truth for "which config branch are we generating".
|
|
*
|
|
* The three generation chains below (DNS, outbounds, route) must be selected by
|
|
* the routing mode, NOT by whether main_node / default_outbound happen to be
|
|
* set. Those two answer a different question: in custom mode default_outbound
|
|
* is legitimately 'nil' (every routing rule picks its own outbound, there is no
|
|
* default) and main_node is never read. Deriving the mode from them made a
|
|
* perfectly normal custom config fall through every branch and emit a config
|
|
* containing none of the user's DNS servers, nodes, rules or rule-sets.
|
|
*/
|
|
const is_custom_mode = (routing_mode === 'custom');
|
|
|
|
let wan_dns = ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0];
|
|
if (!wan_dns)
|
|
wan_dns = (routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5';
|
|
|
|
const dns_port = uci.get(uciconfig, uciinfra, 'dns_port') || '5333';
|
|
|
|
const ntp_server = uci.get(uciconfig, uciinfra, 'ntp_server') || 'time.apple.com';
|
|
|
|
const ipv6_support = uci.get(uciconfig, ucimain, 'ipv6_support') || '0';
|
|
|
|
let main_node, main_udp_node, dedicated_udp_node, default_outbound, default_outbound_dns,
|
|
domain_strategy, dns_server, china_dns_server, dns_default_strategy,
|
|
dns_default_server, dns_disable_cache, dns_disable_cache_expire,
|
|
dns_client_subnet, direct_domain_list, proxy_domain_list;
|
|
|
|
if (routing_mode !== 'custom') {
|
|
main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil';
|
|
main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil';
|
|
dedicated_udp_node = !isEmpty(main_udp_node) && !(main_udp_node in ['same', main_node]);
|
|
|
|
dns_server = uci.get(uciconfig, ucimain, 'dns_server');
|
|
if (isEmpty(dns_server) || dns_server === 'wan')
|
|
dns_server = wan_dns;
|
|
|
|
if (routing_mode === 'bypass_mainland_china') {
|
|
china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
|
|
if (isEmpty(china_dns_server) || type(china_dns_server) !== 'string' || china_dns_server === 'wan')
|
|
china_dns_server = '223.5.5.5';
|
|
}
|
|
dns_default_strategy = (ipv6_support !== '1') ? 'ipv4_only' : null;
|
|
|
|
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
|
|
direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
|
|
|
|
const proxy_list_raw = readfile(HP_DIR + '/resources/proxy_list.txt');
|
|
proxy_domain_list = proxy_list_raw ? split(trim(proxy_list_raw), /[\r\n]/) : [];
|
|
|
|
} else {
|
|
/* DNS settings */
|
|
dns_default_strategy = uci.get(uciconfig, ucidnssetting, 'default_strategy');
|
|
dns_default_server = uci.get(uciconfig, ucidnssetting, 'default_server');
|
|
dns_disable_cache = uci.get(uciconfig, ucidnssetting, 'disable_cache');
|
|
dns_disable_cache_expire = uci.get(uciconfig, ucidnssetting, 'disable_cache_expire');
|
|
dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet');
|
|
|
|
/* Routing settings */
|
|
default_outbound = uci.get(uciconfig, uciroutingsetting, 'default_outbound') || 'nil';
|
|
default_outbound_dns = uci.get(uciconfig, uciroutingsetting, 'default_outbound_dns') || 'default-dns';
|
|
domain_strategy = uci.get(uciconfig, uciroutingsetting, 'domain_strategy');
|
|
}
|
|
|
|
const dns_optimistic_cache = uci.get(uciconfig, ucidnssetting, 'optimistic_cache') || '0',
|
|
dns_optimistic_timeout = uci.get(uciconfig, ucidnssetting, 'optimistic_timeout'),
|
|
dns_query_timeout = uci.get(uciconfig, ucidnssetting, 'dns_timeout'),
|
|
dns_store_dns = uci.get(uciconfig, ucidnssetting, 'cache_file_store_dns') || '0';
|
|
|
|
const proxy_mode = uci.get(uciconfig, ucimain, 'proxy_mode') || 'redirect_tproxy',
|
|
default_interface = uci.get(uciconfig, ucicontrol, 'bind_interface');
|
|
|
|
const mixed_port = uci.get(uciconfig, uciinfra, 'mixed_port') || '5330';
|
|
|
|
let self_mark, redirect_port, tproxy_port, tun_name,
|
|
tun_addr4, tun_addr6, tun_mtu, tcpip_stack,
|
|
endpoint_independent_nat, udp_timeout;
|
|
|
|
if (routing_mode === 'custom')
|
|
udp_timeout = uci.get(uciconfig, uciroutingsetting, 'udp_timeout');
|
|
else
|
|
udp_timeout = uci.get(uciconfig, 'infra', 'udp_timeout');
|
|
|
|
if (match(proxy_mode, /redirect/)) {
|
|
self_mark = uci.get(uciconfig, 'infra', 'self_mark') || '100';
|
|
redirect_port = uci.get(uciconfig, 'infra', 'redirect_port') || '5331';
|
|
}
|
|
if (match(proxy_mode, /tproxy/))
|
|
if (main_udp_node !== 'nil' || routing_mode === 'custom')
|
|
tproxy_port = uci.get(uciconfig, 'infra', 'tproxy_port') || '5332';
|
|
if (match(proxy_mode, /tun/)) {
|
|
tun_name = uci.get(uciconfig, uciinfra, 'tun_name') || 'singtun0';
|
|
tun_addr4 = uci.get(uciconfig, uciinfra, 'tun_addr4') || '172.19.0.1/30';
|
|
tun_addr6 = uci.get(uciconfig, uciinfra, 'tun_addr6') || 'fdfe:dcba:9876::1/126';
|
|
tun_mtu = uci.get(uciconfig, uciinfra, 'tun_mtu') || '9000';
|
|
tcpip_stack = 'system';
|
|
if (routing_mode === 'custom') {
|
|
tcpip_stack = uci.get(uciconfig, uciroutingsetting, 'tcpip_stack') || 'system';
|
|
endpoint_independent_nat = uci.get(uciconfig, uciroutingsetting, 'endpoint_independent_nat');
|
|
}
|
|
}
|
|
|
|
const log_level = uci.get(uciconfig, ucimain, 'log_level') || 'warn';
|
|
/* UCI config end */
|
|
|
|
const tun_dns_mode_raw = uci.get(uciconfig, ucimain, 'tun_dns_mode'),
|
|
tun_dns_address = uci.get(uciconfig, ucimain, 'tun_dns_address'),
|
|
udp_mapping_raw = uci.get(uciconfig, ucimain, 'udp_mapping'),
|
|
udp_filtering_raw = uci.get(uciconfig, ucimain, 'udp_filtering'),
|
|
udp_nat_max = strToInt(uci.get(uciconfig, ucimain, 'udp_nat_max'));
|
|
|
|
const tun_dns_mode = (tun_dns_mode_raw === 'default') ? '' : tun_dns_mode_raw,
|
|
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
|
|
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
|
|
|
|
/* Config helper start */
|
|
/*
|
|
* Direct-node destination override, keyed by node section name. It must be
|
|
* declared before the helpers that touch it: ucode resolves let/const
|
|
* lexically and does not hoist them, so a function defined earlier would
|
|
* resolve the name as an undeclared global and throw under strict mode.
|
|
*/
|
|
const direct_overrides = {};
|
|
|
|
function parse_port(strport) {
|
|
if (type(strport) !== 'array' || isEmpty(strport))
|
|
return null;
|
|
|
|
let ports = [];
|
|
for (let i in strport)
|
|
push(ports, int(i));
|
|
|
|
return ports;
|
|
|
|
}
|
|
|
|
function parse_dnsserver(server_addr, default_protocol) {
|
|
if (isEmpty(server_addr))
|
|
return null;
|
|
|
|
if (!match(server_addr, /:\/\//))
|
|
server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr);
|
|
server_addr = parseURL(server_addr);
|
|
|
|
return {
|
|
type: server_addr.protocol,
|
|
server: server_addr.hostname,
|
|
server_port: strToInt(server_addr.port),
|
|
path: (server_addr.pathname !== '/') ? server_addr.pathname : null,
|
|
}
|
|
}
|
|
|
|
function parse_dnsquery(strquery) {
|
|
if (type(strquery) !== 'array' || isEmpty(strquery))
|
|
return null;
|
|
|
|
let querys = [];
|
|
for (let i in strquery)
|
|
isnan(int(i)) ? push(querys, i) : push(querys, int(i));
|
|
|
|
return querys;
|
|
|
|
}
|
|
|
|
function generate_endpoint(node) {
|
|
if (type(node) !== 'object' || isEmpty(node))
|
|
return null;
|
|
|
|
const endpoint = {
|
|
type: node.type,
|
|
tag: 'cfg-' + node['.name'] + '-out',
|
|
address: node.wireguard_local_address,
|
|
mtu: strToInt(node.wireguard_mtu),
|
|
private_key: node.wireguard_private_key,
|
|
peers: (node.type === 'wireguard') ? [
|
|
{
|
|
address: node.address,
|
|
port: strToInt(node.port),
|
|
allowed_ips: [
|
|
'0.0.0.0/0',
|
|
'::/0'
|
|
],
|
|
persistent_keepalive_interval: strToInt(node.wireguard_persistent_keepalive_interval),
|
|
public_key: node.wireguard_peer_public_key,
|
|
pre_shared_key: node.wireguard_pre_shared_key,
|
|
reserved: parse_port(node.wireguard_reserved),
|
|
}
|
|
] : null,
|
|
system: (node.type === 'wireguard') ? false : null,
|
|
tcp_fast_open: strToBool(node.tcp_fast_open),
|
|
tcp_multi_path: strToBool(node.tcp_multi_path),
|
|
udp_fragment: strToBool(node.udp_fragment),
|
|
udp_mapping: !isEmpty(udp_mapping) ? udp_mapping : null,
|
|
udp_filtering: !isEmpty(udp_filtering) ? udp_filtering : null,
|
|
udp_nat_max: udp_nat_max
|
|
};
|
|
|
|
return endpoint;
|
|
}
|
|
|
|
function generate_outbound(node) {
|
|
if (type(node) !== 'object' || isEmpty(node))
|
|
return null;
|
|
|
|
const outbound = {
|
|
type: node.type,
|
|
tag: 'cfg-' + node['.name'] + '-out',
|
|
routing_mark: strToInt(self_mark),
|
|
|
|
server: node.address,
|
|
server_port: strToInt(node.port),
|
|
/* Hysteria(2) */
|
|
server_ports: node.hysteria_hopping_port,
|
|
|
|
username: (node.type !== 'ssh') ? node.username : null,
|
|
user: (node.type === 'ssh') ? node.username : null,
|
|
/* Snell authenticates with psk instead of password */
|
|
password: (node.type !== 'snell') ? node.password : null,
|
|
psk: (node.type === 'snell') ? node.password : null,
|
|
userkey: (node.type === 'snell') ? node.snell_userkey : null,
|
|
reuse: (node.type === 'snell') ? strToBool(node.snell_reuse) : null,
|
|
/* Snell v4: HTTP obfuscation; v6: traffic shaping mode */
|
|
obfs_mode: (node.type === 'snell') ? (node.snell_obfs_mode || null) : null,
|
|
obfs_host: (node.type === 'snell') ? (node.snell_obfs_host || null) : null,
|
|
mode: (node.type === 'snell') ? (node.snell_mode || null) : null,
|
|
|
|
/* Direct */
|
|
proxy_protocol: strToInt(node.proxy_protocol),
|
|
/* AnyTLS */
|
|
idle_session_check_interval: strToTime(node.anytls_idle_session_check_interval),
|
|
idle_session_timeout: strToTime(node.anytls_idle_session_timeout),
|
|
min_idle_session: strToInt(node.anytls_min_idle_session),
|
|
/* Hysteria (2) */
|
|
hop_interval: strToTime(node.hysteria_hop_interval),
|
|
hop_interval_max: strToTime(node.hysteria_hop_interval_max),
|
|
up_mbps: strToInt(node.hysteria_up_mbps),
|
|
down_mbps: strToInt(node.hysteria_down_mbps),
|
|
obfs: node.hysteria_obfs_type ? {
|
|
type: node.hysteria_obfs_type,
|
|
password: node.hysteria_obfs_password,
|
|
min_packet_size: strToInt(node.hysteria_obfs_min_packet_size),
|
|
max_packet_size: strToInt(node.hysteria_obfs_max_packet_size)
|
|
} : node.hysteria_obfs_password,
|
|
auth: (node.hysteria_auth_type === 'base64') ? node.hysteria_auth_payload : null,
|
|
auth_str: (node.hysteria_auth_type === 'string') ? node.hysteria_auth_payload : null,
|
|
/* sing-box 1.14: Hysteria2 QUIC params (Hysteria v1 recv-window tuning removed upstream) */
|
|
bbr_profile: (node.type === 'hysteria2') ? (node.hysteria_bbr_profile || null) : null,
|
|
disable_chrome_parrot: (node.type === 'hysteria2' && node.hysteria_disable_chrome_parrot === '1') ? true : null,
|
|
/* Shadowsocks */
|
|
method: node.shadowsocks_encrypt_method,
|
|
plugin: node.shadowsocks_plugin,
|
|
plugin_opts: node.shadowsocks_plugin_opts,
|
|
/* ShadowTLS / Socks / Snell */
|
|
version: (node.type === 'shadowtls') ? strToInt(node.shadowtls_version) : ((node.type === 'socks') ? node.socks_version : ((node.type === 'snell') ? (strToInt(node.snell_version) || 4) : null)),
|
|
/* SSH */
|
|
client_version: node.ssh_client_version,
|
|
host_key: node.ssh_host_key,
|
|
host_key_algorithms: node.ssh_host_key_algo,
|
|
private_key: node.ssh_priv_key,
|
|
private_key_passphrase: node.ssh_priv_key_pp,
|
|
/* Tuic */
|
|
uuid: node.uuid,
|
|
congestion_control: node.tuic_congestion_control,
|
|
udp_relay_mode: node.tuic_udp_relay_mode,
|
|
udp_over_stream: strToBool(node.tuic_udp_over_stream),
|
|
zero_rtt_handshake: strToBool(node.tuic_enable_zero_rtt),
|
|
heartbeat: strToTime(node.tuic_heartbeat),
|
|
/* VLESS / VMess */
|
|
flow: node.vless_flow,
|
|
alter_id: strToInt(node.vmess_alterid),
|
|
security: node.vmess_encrypt,
|
|
global_padding: strToBool(node.vmess_global_padding),
|
|
authenticated_length: strToBool(node.vmess_authenticated_length),
|
|
packet_encoding: node.packet_encoding,
|
|
|
|
multiplex: (node.multiplex === '1') ? {
|
|
enabled: true,
|
|
protocol: node.multiplex_protocol,
|
|
max_connections: strToInt(node.multiplex_max_connections),
|
|
min_streams: strToInt(node.multiplex_min_streams),
|
|
max_streams: strToInt(node.multiplex_max_streams),
|
|
padding: strToBool(node.multiplex_padding),
|
|
brutal: (node.multiplex_brutal === '1') ? {
|
|
enabled: true,
|
|
up_mbps: strToInt(node.multiplex_brutal_up),
|
|
down_mbps: strToInt(node.multiplex_brutal_down)
|
|
} : null
|
|
} : null,
|
|
tls: buildTLSObject(node, false),
|
|
transport: buildTransportObject(node, false),
|
|
udp_over_tcp: (node.udp_over_tcp === '1') ? {
|
|
enabled: true,
|
|
version: strToInt(node.udp_over_tcp_version)
|
|
} : null,
|
|
tcp_fast_open: strToBool(node.tcp_fast_open),
|
|
tcp_multi_path: strToBool(node.tcp_multi_path),
|
|
udp_fragment: strToBool(node.udp_fragment)
|
|
};
|
|
|
|
/* Direct-node destination override: sing-box removed these options from
|
|
the direct outbound since 1.13; emit them via the route action instead */
|
|
if (node.type === 'direct' && (!isEmpty(node.override_address) || !isEmpty(node.override_port)))
|
|
direct_overrides[node['.name']] = {
|
|
override_address: node.override_address,
|
|
override_port: strToInt(node.override_port)
|
|
};
|
|
|
|
return outbound;
|
|
}
|
|
|
|
function get_outbound(cfg) {
|
|
if (isEmpty(cfg))
|
|
return null;
|
|
|
|
if (type(cfg) === 'array') {
|
|
if ('any-out' in cfg)
|
|
return 'any';
|
|
|
|
let outbounds = [];
|
|
for (let i in cfg)
|
|
push(outbounds, get_outbound(i));
|
|
return outbounds;
|
|
} else {
|
|
switch (cfg) {
|
|
case 'block-out':
|
|
case 'direct-out':
|
|
return cfg;
|
|
default:
|
|
const node = uci.get(uciconfig, cfg, 'node');
|
|
if (isEmpty(node))
|
|
die(sprintf("%s's node is missing, please check your configuration.", cfg));
|
|
else if (node === 'urltest')
|
|
return 'cfg-' + cfg + '-out';
|
|
else
|
|
return 'cfg-' + node + '-out';
|
|
}
|
|
}
|
|
}
|
|
|
|
function get_direct_override(outbound_selector) {
|
|
if (type(outbound_selector) === 'array' || isEmpty(outbound_selector))
|
|
return null;
|
|
|
|
switch (outbound_selector) {
|
|
case 'direct-out':
|
|
case 'block-out':
|
|
return null;
|
|
default:
|
|
const node = uci.get(uciconfig, outbound_selector, 'node');
|
|
return (!isEmpty(node) && node !== 'urltest') ? (direct_overrides[node] || null) : null;
|
|
}
|
|
}
|
|
|
|
function get_resolver(cfg) {
|
|
if (isEmpty(cfg))
|
|
return null;
|
|
|
|
switch (cfg) {
|
|
case 'default-dns':
|
|
case 'system-dns':
|
|
return cfg;
|
|
default:
|
|
return 'cfg-' + cfg + '-dns';
|
|
}
|
|
}
|
|
|
|
function get_ruleset(cfg) {
|
|
if (isEmpty(cfg))
|
|
return null;
|
|
|
|
let rules = [];
|
|
for (let i in cfg)
|
|
push(rules, isEmpty(i) ? null : 'cfg-' + i + '-rule');
|
|
return rules;
|
|
}
|
|
|
|
function isDirectOutboundTag(tag) {
|
|
if (isEmpty(tag) || tag === 'block-out')
|
|
return false;
|
|
if (tag === 'direct-out')
|
|
return true;
|
|
|
|
const node_name = uci.get(uciconfig, tag, 'node') || tag;
|
|
const node = uci.get_all(uciconfig, node_name);
|
|
return !isEmpty(node) && node.type === 'direct';
|
|
}
|
|
|
|
/*
|
|
* Remote rule-sets are downloaded through a top-level http_client in sing-box
|
|
* 1.14 (the per-rule-set download_detour was removed). One client is created
|
|
* per distinct detour and reused by every rule-set that dials through it.
|
|
*/
|
|
const http_clients = [];
|
|
const http_seen = {};
|
|
|
|
function http_client_for(detour) {
|
|
if (isEmpty(detour))
|
|
detour = (routing_mode === 'custom') ? (get_outbound(default_outbound) || 'direct-out') : 'direct-out';
|
|
|
|
const tag = 'hp-' + detour;
|
|
if (http_seen[detour])
|
|
return tag;
|
|
|
|
http_seen[detour] = true;
|
|
const client = { tag: tag };
|
|
/* sing-box 1.14 rejects an empty detour on the direct outbound (pure TUN
|
|
mode has no self_mark on it); omitting detour uses the same system
|
|
direct dialer, so behaviour is unchanged. */
|
|
if (!(isEmpty(self_mark) && isDirectOutboundTag(detour)))
|
|
client.detour = detour;
|
|
|
|
push(http_clients, client);
|
|
|
|
return tag;
|
|
}
|
|
|
|
/*
|
|
* sing-box requires dns.servers[].headers to be a map, while the LuCI field is
|
|
* a dynamic list of "Name: value" lines (and older configs may hold either).
|
|
*/
|
|
function parse_headers(headers) {
|
|
if (isEmpty(headers))
|
|
return null;
|
|
|
|
if (type(headers) === 'object')
|
|
return headers;
|
|
|
|
const lines = (type(headers) === 'array') ? headers : [headers];
|
|
const out = {};
|
|
for (let line in lines) {
|
|
const pos = index(line, ':');
|
|
if (pos > 0)
|
|
out[trim(substr(line, 0, pos))] = trim(substr(line, pos + 1));
|
|
}
|
|
|
|
return isEmpty(out) ? null : out;
|
|
}
|
|
/* Config helper end */
|
|
|
|
const config = {};
|
|
|
|
/* Log */
|
|
config.log = {
|
|
disabled: false,
|
|
level: log_level,
|
|
output: RUN_DIR + '/sing-box-c.log',
|
|
timestamp: true
|
|
};
|
|
|
|
/* NTP */
|
|
if (!isEmpty(ntp_server))
|
|
config.ntp = {
|
|
enabled: true,
|
|
server: ntp_server,
|
|
detour: 'direct-out',
|
|
domain_resolver: 'default-dns',
|
|
};
|
|
|
|
/* DNS start */
|
|
/* Default settings */
|
|
config.dns = {
|
|
servers: [
|
|
{
|
|
tag: 'default-dns',
|
|
type: 'udp',
|
|
server: wan_dns,
|
|
detour: self_mark ? 'direct-out' : null
|
|
},
|
|
{
|
|
tag: 'system-dns',
|
|
type: 'local',
|
|
detour: self_mark ? 'direct-out' : null
|
|
}
|
|
],
|
|
rules: [],
|
|
strategy: dns_default_strategy,
|
|
disable_cache: strToBool(dns_disable_cache),
|
|
disable_expire: strToBool(dns_disable_cache_expire),
|
|
client_subnet: dns_client_subnet,
|
|
optimistic: (dns_optimistic_cache === '1') ? {
|
|
enabled: true,
|
|
timeout: !isEmpty(dns_optimistic_timeout) ? dns_optimistic_timeout : '3d'
|
|
} : null,
|
|
timeout: !isEmpty(dns_query_timeout) ? strToTime(dns_query_timeout) : null
|
|
};
|
|
|
|
if (!isEmpty(main_node)) {
|
|
/* Main DNS */
|
|
push(config.dns.servers, {
|
|
tag: 'main-dns',
|
|
domain_resolver: {
|
|
server: 'default-dns',
|
|
strategy: (ipv6_support !== '1') ? 'ipv4_only' : null
|
|
},
|
|
detour: 'main-out',
|
|
...parse_dnsserver(dns_server, 'tcp')
|
|
});
|
|
config.dns.final = 'main-dns';
|
|
|
|
if (length(direct_domain_list))
|
|
push(config.dns.rules, {
|
|
rule_set: 'direct-domain',
|
|
action: 'route',
|
|
server: (routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
|
|
});
|
|
|
|
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains.
|
|
Opt out with `block_https_rr 0` when clients should use HTTPS RR/ECH. */
|
|
if (uci.get(uciconfig, ucimain, 'block_https_rr') !== '0')
|
|
push(config.dns.rules, {
|
|
query_type: [64, 65],
|
|
action: 'reject'
|
|
});
|
|
|
|
if (routing_mode === 'bypass_mainland_china') {
|
|
push(config.dns.servers, {
|
|
tag: 'china-dns',
|
|
domain_resolver: {
|
|
server: 'default-dns',
|
|
/* follow the IPv6 setting like main-dns does, instead of
|
|
preferring AAAA even on IPv4-only setups */
|
|
strategy: (ipv6_support !== '1') ? 'ipv4_only' : null
|
|
},
|
|
detour: self_mark ? 'direct-out' : null,
|
|
...parse_dnsserver(china_dns_server)
|
|
});
|
|
|
|
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
|
|
default-dns directly: china-dns (223.5.5.5) has intermittent
|
|
multi-second first responses for NAPTR, causing 'context deadline
|
|
exceeded' (e.g. sipgz12.hbq.r.10086.cn) */
|
|
push(config.dns.rules, {
|
|
query_type: [35],
|
|
domain_suffix: ['r.10086.cn', '10086.cn', 'pub.3gppnetwork.org'],
|
|
action: 'route',
|
|
server: 'default-dns'
|
|
});
|
|
|
|
if (length(proxy_domain_list))
|
|
push(config.dns.rules, {
|
|
rule_set: 'proxy-domain',
|
|
action: 'route',
|
|
server: 'main-dns'
|
|
});
|
|
|
|
push(config.dns.rules, {
|
|
rule_set: 'geosite-cn',
|
|
action: 'route',
|
|
server: 'china-dns'
|
|
});
|
|
|
|
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
|
|
if (uci.get(uciconfig, ucimain, 'cn_ip_fallback') === '1') {
|
|
push(config.dns.rules, {
|
|
action: 'evaluate',
|
|
server: 'main-dns',
|
|
tag: 'cn-fallback'
|
|
});
|
|
push(config.dns.rules, {
|
|
match_response: 'cn-fallback',
|
|
rule_set: 'geoip-cn',
|
|
action: 'route',
|
|
server: 'china-dns'
|
|
});
|
|
}
|
|
}
|
|
} else if (is_custom_mode) {
|
|
/* DNS servers */
|
|
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
|
|
if (cfg.enabled !== '1')
|
|
return;
|
|
|
|
let outbound = get_outbound(cfg.outbound);
|
|
if (outbound === 'direct-out' && isEmpty(self_mark))
|
|
outbound = null;
|
|
|
|
push(config.dns.servers, {
|
|
tag: 'cfg-' + cfg['.name'] + '-dns',
|
|
type: cfg.type,
|
|
server: cfg.server,
|
|
server_port: strToInt(cfg.server_port),
|
|
path: cfg.path,
|
|
headers: parse_headers(cfg.headers),
|
|
tls: cfg.tls_sni ? {
|
|
enabled: true,
|
|
server_name: cfg.tls_sni
|
|
} : null,
|
|
domain_resolver: (cfg.address_resolver || cfg.address_strategy) ? {
|
|
server: get_resolver(cfg.address_resolver || dns_default_server),
|
|
strategy: cfg.address_strategy
|
|
} : null,
|
|
detour: outbound
|
|
});
|
|
});
|
|
|
|
/* DNS rules */
|
|
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
|
|
evaluate action; deprecated strategy/accept_empty fields are dropped. */
|
|
const builtin_dns_rules = [];
|
|
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
|
|
if (cfg.enabled !== '1')
|
|
return;
|
|
|
|
const rule = {
|
|
ip_version: strToInt(cfg.ip_version),
|
|
query_type: parse_dnsquery(cfg.query_type),
|
|
network: cfg.network,
|
|
protocol: cfg.protocol,
|
|
domain: cfg.domain,
|
|
domain_suffix: cfg.domain_suffix,
|
|
domain_keyword: cfg.domain_keyword,
|
|
domain_regex: cfg.domain_regex,
|
|
port: parse_port(cfg.port),
|
|
port_range: cfg.port_range,
|
|
source_ip_cidr: cfg.source_ip_cidr,
|
|
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
|
source_port: parse_port(cfg.source_port),
|
|
source_port_range: cfg.source_port_range,
|
|
process_name: cfg.process_name,
|
|
process_path: cfg.process_path,
|
|
process_path_regex: cfg.process_path_regex,
|
|
user: cfg.user,
|
|
rule_set: get_ruleset(cfg.rule_set),
|
|
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
|
invert: strToBool(cfg.invert),
|
|
race: strToBool(cfg.race),
|
|
speculative: strToBool(cfg.speculative),
|
|
action: cfg.action,
|
|
server: get_resolver(cfg.server),
|
|
disable_cache: strToBool(cfg.dns_disable_cache),
|
|
disable_optimistic_cache: strToBool(cfg.disable_optimistic_cache),
|
|
rewrite_ttl: strToInt(cfg.rewrite_ttl),
|
|
timeout: strToTime(cfg.dns_timeout),
|
|
client_subnet: cfg.client_subnet,
|
|
remove_client_subnet: strToBool(cfg.remove_client_subnet),
|
|
method: cfg.reject_method,
|
|
no_drop: strToBool(cfg.reject_no_drop),
|
|
rcode: cfg.predefined_rcode,
|
|
answer: cfg.predefined_answer,
|
|
ns: cfg.predefined_ns,
|
|
extra: cfg.predefined_extra
|
|
};
|
|
|
|
if (cfg.action === 'evaluate')
|
|
rule.tag = cfg.evaluate_tag || null;
|
|
|
|
if (cfg.match_response && cfg.match_response !== '0')
|
|
rule.match_response = (cfg.match_response === '1') ? true : cfg.match_response;
|
|
|
|
rule.query_client_subnet = cfg.query_client_subnet;
|
|
rule.query_dnssec = strToBool(cfg.query_dnssec);
|
|
rule.response_rcode = cfg.response_rcode;
|
|
rule.response_answer = cfg.response_answer;
|
|
rule.response_ns = cfg.response_ns;
|
|
rule.response_extra = cfg.response_extra;
|
|
rule.source_mac_address = cfg.source_mac_address;
|
|
rule.source_hostname = cfg.source_hostname;
|
|
|
|
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
|
|
if (legacy_filter && !rule.match_response && cfg.action === 'route') {
|
|
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
|
|
paradigm. Carry the original query-matching fields onto the evaluate
|
|
prefix rule so only queries that would have hit this rule get
|
|
pre-resolved; an unconditional evaluate would resolve every query. */
|
|
const eval_tag = '_hp_eval_' + cfg['.name'];
|
|
const eval_rule = {
|
|
action: 'evaluate',
|
|
server: get_resolver(cfg.server),
|
|
tag: eval_tag
|
|
};
|
|
const eval_match_fields = [
|
|
'inbound', 'ip_version', 'query_type', 'network', 'protocol', 'auth_user',
|
|
'domain', 'domain_suffix', 'domain_keyword', 'domain_regex',
|
|
'port', 'port_range', 'source_ip_cidr', 'source_ip_is_private',
|
|
'source_port', 'source_port_range', 'process_name', 'process_path',
|
|
'process_path_regex', 'user', 'rule_set', 'rule_set_ip_cidr_match_source',
|
|
'invert', 'query_client_subnet', 'query_dnssec', 'source_mac_address',
|
|
'source_hostname'
|
|
];
|
|
for (let f in eval_match_fields)
|
|
eval_rule[f] = rule[f];
|
|
push(builtin_dns_rules, eval_rule);
|
|
rule.match_response = eval_tag;
|
|
}
|
|
|
|
/* ip_cidr / ip_is_private are only valid with match_response in 1.14 */
|
|
if (rule.match_response) {
|
|
rule.ip_cidr = cfg.ip_cidr;
|
|
rule.ip_is_private = strToBool(cfg.ip_is_private);
|
|
}
|
|
|
|
/* sing-box rejects the whole config when a route/evaluate action has no
|
|
server, so drop such a rule with a reason instead. */
|
|
if ((rule.action === 'route' || rule.action === 'evaluate') && isEmpty(rule.server)) {
|
|
warn(sprintf("homeproxy: DNS rule '%s' has no server configured, skipping it.", cfg['.name']));
|
|
return;
|
|
}
|
|
|
|
push(builtin_dns_rules, rule);
|
|
});
|
|
config.dns.rules = builtin_dns_rules;
|
|
|
|
config.dns.final = get_resolver(dns_default_server);
|
|
}
|
|
/* DNS end */
|
|
|
|
/* Inbound start */
|
|
config.inbounds = [];
|
|
|
|
/*
|
|
* dns-in is the local listener the DNS chain hands queries to: dnsmasq
|
|
* forwards to 127.0.0.1#<dns_port> and the nft DNS hijack redirects to
|
|
* dnsmasq, not here. It used to listen on '::', which published a
|
|
* proxy-backed resolver to every LAN client on the DNS port; loopback is the
|
|
* whole reachable surface it needs. mixed-in stays on '::' - that one is the
|
|
* SOCKS/HTTP listener clients are meant to reach.
|
|
*/
|
|
push(config.inbounds, {
|
|
type: 'direct',
|
|
tag: 'dns-in',
|
|
listen: '127.0.0.1',
|
|
listen_port: int(dns_port)
|
|
});
|
|
|
|
push(config.inbounds, {
|
|
type: 'mixed',
|
|
tag: 'mixed-in',
|
|
listen: '::',
|
|
listen_port: int(mixed_port),
|
|
udp_timeout: strToTime(udp_timeout),
|
|
set_system_proxy: false
|
|
});
|
|
|
|
if (match(proxy_mode, /redirect/))
|
|
push(config.inbounds, {
|
|
type: 'redirect',
|
|
tag: 'redirect-in',
|
|
|
|
listen: '::',
|
|
listen_port: int(redirect_port)
|
|
});
|
|
if (match(proxy_mode, /tproxy/))
|
|
push(config.inbounds, {
|
|
type: 'tproxy',
|
|
tag: 'tproxy-in',
|
|
|
|
listen: '::',
|
|
listen_port: int(tproxy_port),
|
|
network: 'udp',
|
|
udp_timeout: strToTime(udp_timeout),
|
|
udp_mapping: !isEmpty(udp_mapping) ? udp_mapping : null,
|
|
udp_filtering: !isEmpty(udp_filtering) ? udp_filtering : null,
|
|
udp_nat_max: udp_nat_max,
|
|
});
|
|
if (match(proxy_mode, /tun/))
|
|
push(config.inbounds, {
|
|
type: 'tun',
|
|
tag: 'tun-in',
|
|
|
|
interface_name: tun_name,
|
|
address: (ipv6_support === '1') ? [tun_addr4, tun_addr6] : [tun_addr4],
|
|
mtu: strToInt(tun_mtu),
|
|
auto_route: false,
|
|
endpoint_independent_nat: strToBool(endpoint_independent_nat),
|
|
udp_timeout: strToTime(udp_timeout),
|
|
dns_mode: !isEmpty(tun_dns_mode) ? tun_dns_mode : null,
|
|
dns_address: !isEmpty(tun_dns_address) ? tun_dns_address : null,
|
|
udp_mapping: !isEmpty(udp_mapping) ? udp_mapping : null,
|
|
udp_filtering: !isEmpty(udp_filtering) ? udp_filtering : null,
|
|
udp_nat_max: udp_nat_max,
|
|
stack: tcpip_stack,
|
|
});
|
|
/* Inbound end */
|
|
|
|
/* Outbound start */
|
|
config.endpoints = [];
|
|
|
|
/* Default outbounds */
|
|
config.outbounds = [
|
|
{
|
|
type: 'direct',
|
|
tag: 'direct-out',
|
|
routing_mark: strToInt(self_mark)
|
|
},
|
|
{
|
|
type: 'block',
|
|
tag: 'block-out'
|
|
}
|
|
];
|
|
|
|
/* Main outbounds */
|
|
if (!isEmpty(main_node)) {
|
|
let urltest_nodes = [];
|
|
|
|
if (main_node === 'urltest') {
|
|
const main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (k) => uci.get(uciconfig, k));
|
|
const main_urltest_interval = uci.get(uciconfig, ucimain, 'main_urltest_interval');
|
|
const main_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_urltest_tolerance');
|
|
|
|
push(config.outbounds, {
|
|
type: 'urltest',
|
|
tag: 'main-out',
|
|
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
|
|
interval: strToTime(main_urltest_interval),
|
|
tolerance: strToInt(main_urltest_tolerance),
|
|
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
|
|
});
|
|
urltest_nodes = main_urltest_nodes;
|
|
} else {
|
|
const main_node_cfg = uci.get_all(uciconfig, main_node) || {};
|
|
if (main_node_cfg.type === 'wireguard') {
|
|
push(config.endpoints, generate_endpoint(main_node_cfg));
|
|
config.endpoints[length(config.endpoints)-1].tag = 'main-out';
|
|
} else {
|
|
push(config.outbounds, generate_outbound(main_node_cfg));
|
|
config.outbounds[length(config.outbounds)-1].tag = 'main-out';
|
|
}
|
|
}
|
|
|
|
if (main_udp_node === 'urltest') {
|
|
const main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [], (k) => uci.get(uciconfig, k));
|
|
const main_udp_urltest_interval = uci.get(uciconfig, ucimain, 'main_udp_urltest_interval');
|
|
const main_udp_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_udp_urltest_tolerance');
|
|
|
|
push(config.outbounds, {
|
|
type: 'urltest',
|
|
tag: 'main-udp-out',
|
|
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
|
|
interval: strToTime(main_udp_urltest_interval),
|
|
tolerance: strToInt(main_udp_urltest_tolerance),
|
|
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
|
|
});
|
|
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
|
|
} else if (dedicated_udp_node) {
|
|
const main_udp_node_cfg = uci.get_all(uciconfig, main_udp_node) || {};
|
|
if (main_udp_node_cfg.type === 'wireguard') {
|
|
push(config.endpoints, generate_endpoint(main_udp_node_cfg));
|
|
config.endpoints[length(config.endpoints)-1].tag = 'main-udp-out';
|
|
} else {
|
|
push(config.outbounds, generate_outbound(main_udp_node_cfg));
|
|
config.outbounds[length(config.outbounds)-1].tag = 'main-udp-out';
|
|
}
|
|
}
|
|
|
|
for (let i in urltest_nodes) {
|
|
const urltest_node = uci.get_all(uciconfig, i) || {};
|
|
if (isEmpty(urltest_node))
|
|
continue;
|
|
if (urltest_node.type === 'wireguard') {
|
|
push(config.endpoints, generate_endpoint(urltest_node));
|
|
config.endpoints[length(config.endpoints)-1].tag = 'cfg-' + i + '-out';
|
|
} else {
|
|
push(config.outbounds, generate_outbound(urltest_node));
|
|
config.outbounds[length(config.outbounds)-1].tag = 'cfg-' + i + '-out';
|
|
}
|
|
}
|
|
} else if (is_custom_mode) {
|
|
let urltest_nodes = [],
|
|
routing_nodes = [];
|
|
|
|
uci.foreach(uciconfig, uciroutingnode, (cfg) => {
|
|
if (cfg.enabled !== '1')
|
|
return;
|
|
|
|
if (cfg.node === 'urltest') {
|
|
const cfg_urltest_nodes = filter(cfg.urltest_nodes || [], (k) => uci.get(uciconfig, k));
|
|
push(config.outbounds, {
|
|
type: 'urltest',
|
|
tag: 'cfg-' + cfg['.name'] + '-out',
|
|
outbounds: map(cfg_urltest_nodes, (k) => `cfg-${k}-out`),
|
|
url: cfg.urltest_url,
|
|
interval: strToTime(cfg.urltest_interval),
|
|
tolerance: strToInt(cfg.urltest_tolerance),
|
|
idle_timeout: strToTime(cfg.urltest_idle_timeout),
|
|
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
|
|
});
|
|
urltest_nodes = [...urltest_nodes, ...filter(cfg_urltest_nodes, (l) => !~index(urltest_nodes, l))];
|
|
} else {
|
|
const outbound = uci.get_all(uciconfig, cfg.node) || {};
|
|
if (outbound.type === 'wireguard') {
|
|
push(config.endpoints, generate_endpoint(outbound));
|
|
config.endpoints[length(config.endpoints)-1].bind_interface = cfg.bind_interface;
|
|
config.endpoints[length(config.endpoints)-1].detour = get_outbound(cfg.outbound);
|
|
if (cfg.domain_resolver)
|
|
config.endpoints[length(config.endpoints)-1].domain_resolver = {
|
|
server: get_resolver(cfg.domain_resolver),
|
|
strategy: cfg.domain_strategy
|
|
};
|
|
} else {
|
|
push(config.outbounds, generate_outbound(outbound));
|
|
config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface;
|
|
config.outbounds[length(config.outbounds)-1].detour = get_outbound(cfg.outbound);
|
|
if (cfg.domain_resolver)
|
|
config.outbounds[length(config.outbounds)-1].domain_resolver = {
|
|
server: get_resolver(cfg.domain_resolver),
|
|
strategy: cfg.domain_strategy
|
|
};
|
|
}
|
|
push(routing_nodes, cfg.node);
|
|
}
|
|
});
|
|
|
|
for (let i in filter(urltest_nodes, (l) => !~index(routing_nodes, l))) {
|
|
const urltest_node = uci.get_all(uciconfig, i) || {};
|
|
if (urltest_node.type === 'wireguard')
|
|
push(config.endpoints, generate_endpoint(urltest_node));
|
|
else
|
|
push(config.outbounds, generate_outbound(urltest_node));
|
|
}
|
|
}
|
|
|
|
if (isEmpty(config.endpoints))
|
|
config.endpoints = null;
|
|
/* Outbound end */
|
|
|
|
/* Routing rules start */
|
|
/* Default settings */
|
|
/*
|
|
* The sniff rule below deliberately carries no `timeout`. Reports from
|
|
* sing-box 1.14.2 show it rejected with "route.rules[1].timeout: json: unknown
|
|
* field". The official builds accept it, so the exact trigger is still open,
|
|
* but the default is already 300ms and omitting the field is behaviour-neutral.
|
|
*/
|
|
config.route = {
|
|
rules: [
|
|
{
|
|
inbound: 'dns-in',
|
|
action: 'hijack-dns'
|
|
},
|
|
{
|
|
action: 'sniff'
|
|
}
|
|
],
|
|
rule_set: [],
|
|
auto_detect_interface: isEmpty(default_interface) ? true : null,
|
|
default_interface: default_interface
|
|
};
|
|
|
|
/* Routing rules */
|
|
if (!isEmpty(main_node)) {
|
|
/* Resolve outbound server domains through the WAN default resolver.
|
|
Do not use china-dns here: china-dns is for resolving mainland China
|
|
destinations, not the proxy node itself. Coupling node bootstrap to
|
|
china_dns_server can break dialing when that resolver is polluted or
|
|
unsuitable for the node domain. */
|
|
config.route.default_domain_resolver = {
|
|
server: 'default-dns',
|
|
strategy: (ipv6_support !== '1') ? 'prefer_ipv4' : null
|
|
};
|
|
|
|
/* Direct list */
|
|
if (length(direct_domain_list))
|
|
push(config.route.rules, {
|
|
rule_set: 'direct-domain',
|
|
action: 'route',
|
|
outbound: 'direct-out'
|
|
});
|
|
|
|
/* Bypass CN traffic: resolve the destination first, then route by IP.
|
|
sing-box does not match an IP-based rule set (geoip-cn) against a domain
|
|
destination unless it is resolved first, so add an explicit resolve
|
|
action; geoip-cn then sends China IPs to direct and everything else falls
|
|
through to main-out (proxy). This avoids relying on the geosite-* domain
|
|
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
|
|
beacons) as "cn" and send them direct to time out. Keep the direct-domain
|
|
fast-path above for known direct domains. */
|
|
if (routing_mode === 'bypass_mainland_china') {
|
|
push(config.route.rules, {
|
|
action: 'resolve',
|
|
strategy: (ipv6_support !== '1') ? 'prefer_ipv4' : null
|
|
});
|
|
push(config.route.rules, {
|
|
rule_set: 'geoip-cn',
|
|
action: 'route',
|
|
outbound: 'direct-out'
|
|
});
|
|
}
|
|
|
|
/* Main UDP out */
|
|
if (dedicated_udp_node) {
|
|
const udp_override = direct_overrides[main_udp_node] || null;
|
|
push(config.route.rules, {
|
|
network: 'udp',
|
|
action: 'route',
|
|
outbound: 'main-udp-out',
|
|
override_address: udp_override ? udp_override.override_address : null,
|
|
override_port: udp_override ? udp_override.override_port : null
|
|
});
|
|
}
|
|
|
|
/* Direct-node destination override, emitted as route-options action
|
|
(direct outbound options removed since sing-box 1.13) */
|
|
const main_override = direct_overrides[main_node] || null;
|
|
if (main_override)
|
|
push(config.route.rules, {
|
|
action: 'route-options',
|
|
override_address: main_override.override_address,
|
|
override_port: main_override.override_port
|
|
});
|
|
|
|
config.route.final = 'main-out';
|
|
|
|
/* Rule set */
|
|
/* Direct list */
|
|
if (length(direct_domain_list))
|
|
push(config.route.rule_set, {
|
|
type: 'inline',
|
|
tag: 'direct-domain',
|
|
rules: [
|
|
{
|
|
domain_keyword: direct_domain_list,
|
|
}
|
|
]
|
|
});
|
|
|
|
/* Proxy list */
|
|
if (length(proxy_domain_list))
|
|
push(config.route.rule_set, {
|
|
type: 'inline',
|
|
tag: 'proxy-domain',
|
|
rules: [
|
|
{
|
|
domain_keyword: proxy_domain_list,
|
|
}
|
|
]
|
|
});
|
|
|
|
if (routing_mode === 'bypass_mainland_china') {
|
|
/*
|
|
* The two rule-sets sing-box actually needs for this mode. Each one is
|
|
* used from the local .srs file when update_resources.sh has fetched
|
|
* it, and only falls back to a remote fetch otherwise: a remote
|
|
* rule-set is initialized at every start (sing-box fails to start when
|
|
* the fetch fails even if nothing references it), which would make a
|
|
* cold boot depend on reaching the CDN through the node. The fallback
|
|
* keeps a fresh install working before the first resource update.
|
|
* geosite-noncn is intentionally not declared: no rule references it,
|
|
* and sing-box downloads it anyway.
|
|
*/
|
|
const preset_rulesets = [
|
|
{
|
|
tag: 'geoip-cn',
|
|
path: HP_DIR + '/resources/geoip_cn.srs',
|
|
url: 'https://v4.gh-proxy.org/https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs'
|
|
},
|
|
{
|
|
tag: 'geosite-cn',
|
|
path: HP_DIR + '/resources/geosite_cn.srs',
|
|
url: 'https://v4.gh-proxy.org/https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs'
|
|
}
|
|
];
|
|
|
|
for (let rs in preset_rulesets) {
|
|
if (access(rs.path)) {
|
|
push(config.route.rule_set, {
|
|
type: 'local',
|
|
tag: rs.tag,
|
|
format: 'binary',
|
|
path: rs.path
|
|
});
|
|
} else {
|
|
push(config.route.rule_set, {
|
|
type: 'remote',
|
|
tag: rs.tag,
|
|
format: 'binary',
|
|
url: rs.url,
|
|
update_interval: '24h',
|
|
http_client: http_client_for('main-out')
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isEmpty(config.route.rule_set))
|
|
config.route.rule_set = null;
|
|
} else if (is_custom_mode) {
|
|
config.route.default_domain_resolver = {
|
|
server: get_resolver(default_outbound_dns)
|
|
};
|
|
|
|
if (uci.get(uciconfig, uciroutingsetting, 'find_neighbor') === '1')
|
|
config.route.find_neighbor = true;
|
|
|
|
if (domain_strategy)
|
|
push(config.route.rules, {
|
|
action: 'resolve',
|
|
strategy: domain_strategy
|
|
});
|
|
|
|
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
|
|
if (cfg.enabled !== '1')
|
|
return null;
|
|
|
|
const rule_outbound = get_outbound(cfg.outbound);
|
|
const rule_direct_override = get_direct_override(cfg.outbound);
|
|
let rule_override_address = cfg.override_address,
|
|
rule_override_port = strToInt(cfg.override_port);
|
|
if (isEmpty(rule_override_address) && isEmpty(rule_override_port) && rule_direct_override) {
|
|
rule_override_address = rule_direct_override.override_address;
|
|
rule_override_port = rule_direct_override.override_port;
|
|
}
|
|
|
|
/* sing-box routing rule: emit match fields plus action-specific fields.
|
|
client is the sniffed client type (only set with protocol quic/ssh);
|
|
resolve adds server/strategy/disable_cache/rewrite_ttl/client_subnet;
|
|
reject adds method/no_drop. Emitted conditionally so a given action
|
|
never carries fields sing-box rejects for it. */
|
|
const rule = {
|
|
ip_version: strToInt(cfg.ip_version),
|
|
protocol: cfg.protocol,
|
|
network: cfg.network,
|
|
client: cfg.client,
|
|
domain: cfg.domain,
|
|
domain_suffix: cfg.domain_suffix,
|
|
domain_keyword: cfg.domain_keyword,
|
|
domain_regex: cfg.domain_regex,
|
|
source_ip_cidr: cfg.source_ip_cidr,
|
|
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
|
ip_cidr: cfg.ip_cidr,
|
|
ip_is_private: strToBool(cfg.ip_is_private),
|
|
source_port: parse_port(cfg.source_port),
|
|
source_port_range: cfg.source_port_range,
|
|
port: parse_port(cfg.port),
|
|
port_range: cfg.port_range,
|
|
process_name: cfg.process_name,
|
|
process_path: cfg.process_path,
|
|
process_path_regex: cfg.process_path_regex,
|
|
user: cfg.user,
|
|
rule_set: get_ruleset(cfg.rule_set),
|
|
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
|
invert: strToBool(cfg.invert),
|
|
action: cfg.action,
|
|
outbound: rule_outbound,
|
|
override_address: rule_override_address,
|
|
override_port: rule_override_port,
|
|
udp_disable_domain_unmapping: strToBool(cfg.udp_disable_domain_unmapping),
|
|
udp_connect: strToBool(cfg.udp_connect),
|
|
udp_timeout: strToTime(cfg.udp_timeout),
|
|
tls_fragment: strToBool(cfg.tls_fragment),
|
|
tls_fragment_fallback_delay: strToMs(cfg.tls_fragment_fallback_delay),
|
|
tls_record_fragment: strToBool(cfg.tls_record_fragment),
|
|
tls_spoof: cfg.tls_spoof || null,
|
|
tls_spoof_method: cfg.tls_spoof_method || null,
|
|
source_mac_address: cfg.source_mac_address,
|
|
source_hostname: cfg.source_hostname
|
|
};
|
|
if (cfg.action === 'resolve') {
|
|
rule.server = get_resolver(cfg.resolve_server);
|
|
rule.strategy = cfg.resolve_strategy;
|
|
rule.disable_cache = strToBool(cfg.resolve_disable_cache);
|
|
rule.disable_optimistic_cache = strToBool(cfg.resolve_disable_optimistic_cache);
|
|
rule.rewrite_ttl = strToInt(cfg.resolve_rewrite_ttl);
|
|
rule.timeout = strToTime(cfg.resolve_timeout);
|
|
rule.client_subnet = cfg.resolve_client_subnet;
|
|
}
|
|
if (cfg.action === 'reject') {
|
|
rule.method = cfg.reject_method;
|
|
rule.no_drop = strToBool(cfg.reject_no_drop);
|
|
}
|
|
push(config.route.rules, rule);
|
|
});
|
|
|
|
/* Direct-node destination override, emitted as route-options action
|
|
(direct outbound options removed since sing-box 1.13) */
|
|
const final_override = get_direct_override(default_outbound);
|
|
if (final_override)
|
|
push(config.route.rules, {
|
|
action: 'route-options',
|
|
override_address: final_override.override_address,
|
|
override_port: final_override.override_port
|
|
});
|
|
|
|
config.route.final = get_outbound(default_outbound);
|
|
|
|
/* Rule set */
|
|
uci.foreach(uciconfig, uciruleset, (cfg) => {
|
|
if (cfg.enabled !== '1')
|
|
return null;
|
|
|
|
const extra_tags = cfg.extra_tags || [];
|
|
let rs_tag = 'cfg-' + cfg['.name'] + '-rule';
|
|
if (length(extra_tags) && cfg.type !== 'inline') {
|
|
rs_tag = [rs_tag];
|
|
for (let t in extra_tags)
|
|
push(rs_tag, 'cfg-' + t + '-rule');
|
|
/* sing-box 1.14: multi-tag requires a {tag} placeholder in the fetch source
|
|
(remote: url and initial_path, local: path) */
|
|
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
|
|
if (!match(fetch_ref, /\{tag\}/))
|
|
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder.", cfg['.name'], cfg.type));
|
|
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path) && !match(cfg.initial_path, /\{tag\}/))
|
|
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg['.name']));
|
|
}
|
|
|
|
const ruleset = {
|
|
type: cfg.type,
|
|
tag: rs_tag,
|
|
format: cfg.format,
|
|
path: cfg.path,
|
|
url: cfg.url,
|
|
update_interval: cfg.update_interval
|
|
};
|
|
/* sing-box 1.14 downloads remote rule-sets through a top-level
|
|
http_client; download_detour no longer exists. */
|
|
if (cfg.type === 'remote')
|
|
ruleset.http_client = http_client_for(get_outbound(cfg.outbound) || get_outbound(default_outbound));
|
|
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
|
|
ruleset.initial_path = cfg.initial_path;
|
|
push(config.route.rule_set, ruleset);
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Belt-and-braces for route.default_domain_resolver.
|
|
*
|
|
* sing-box 1.14 removed the legacy outbound DNS rules and refuses to start when
|
|
* a dial-fields user has a domain to resolve without a resolver. The exemption
|
|
* for "only one DNS server configured" never applies here: default-dns and
|
|
* system-dns are emitted unconditionally, so every config has at least two.
|
|
* That makes this a global requirement, not a per-mode one, so it must not
|
|
* depend on which branch above ran. The branches already pick the right
|
|
* resolver (the WAN resolver, or default_outbound_dns in custom mode); this
|
|
* only covers the degenerate "not custom mode and no main node" case.
|
|
*/
|
|
if (isEmpty(config.route.default_domain_resolver))
|
|
config.route.default_domain_resolver = { server: 'default-dns' };
|
|
|
|
/* Remote rule-sets download through the http_clients collected while the
|
|
rule-sets were built (see http_client_for). */
|
|
if (length(http_clients))
|
|
config.http_clients = http_clients;
|
|
/* Routing rules end */
|
|
|
|
/* Experimental start */
|
|
if (routing_mode in ['bypass_mainland_china', 'custom']) {
|
|
config.experimental = {
|
|
cache_file: {
|
|
enabled: true,
|
|
path: '/etc/homeproxy/cache.db',
|
|
store_dns: (dns_store_dns === '1') ? true : null
|
|
}
|
|
};
|
|
}
|
|
/* Experimental end */
|
|
|
|
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
|
|
|
|
system('mkdir -p ' + RUN_DIR);
|
|
const client_tmp = RUN_DIR + '/sing-box-c.json.tmp';
|
|
writefile(client_tmp, sprintf('%.J\n', removeBlankAttrs(config)));
|
|
if (system('/usr/bin/sing-box check --config ' + client_tmp) !== 0) {
|
|
system('rm -f ' + client_tmp);
|
|
exit(1);
|
|
}
|
|
system('mv -f ' + client_tmp + ' ' + RUN_DIR + '/sing-box-c.json');
|
|
/* The generated config carries every node credential (passwords, UUIDs,
|
|
private keys) and writefile() has no mode argument, so it lands with the
|
|
process umask; chmod it to the sing-box user only. */
|
|
system('chmod 600 ' + RUN_DIR + '/sing-box-c.json');
|