✨ Sync 2026-10-11 20:48:25

This commit is contained in:
github-actions[bot] committed 2026-10-11 20:48:25 +08:00
1 parent 78f0ba8251
commit 5ce75c9f2b
45 files changed
+54 -879

No files matched your search

@@ -21,8 +21,6 @@ if (match(proxy_mode, /tun/)) {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node !== 'nil') {
/* the name is interpolated into an nftables rule: keep it to what an
interface name can actually be */
const raw_tun = trim(uci.get(cfgname, 'infra', 'tun_name') ?? '');
tun_name = match(raw_tun, /^[A-Za-z0-9._-]{1,15}$/) ? raw_tun : 'singtun0';
}
@@ -1,21 +1,6 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Single source of truth for every fw4 chain and set owned by homeproxy.
#
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
# decides which of them actually exist for the current proxy mode.
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
#
# When you add or remove a chain/set in firewall_post.ut, update this list in
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
# fails when they drift.
#
# Each object is cleaned up independently: the cleanup loop deletes one at a
# time so that a chain/set which does not exist in the current mode cannot
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
# every preceding command in that case.
HP_FW4_CHAINS="
homeproxy_output_redir
@@ -20,9 +20,7 @@ import {
const ubus = connect();
/* const features = ubus.call('luci.homeproxy', 'singbox_get_features') || {}; */
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
@@ -45,17 +43,6 @@ 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];
@@ -96,14 +83,12 @@ if (routing_mode !== 'custom') {
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');
@@ -148,7 +133,6 @@ if (match(proxy_mode, /tun/)) {
}
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'),
@@ -160,13 +144,6 @@ 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) {
@@ -256,28 +233,22 @@ function generate_outbound(node) {
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),
@@ -290,29 +261,23 @@ function generate_outbound(node) {
} : 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,
@@ -344,8 +309,6 @@ function generate_outbound(node) {
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,
@@ -432,11 +395,6 @@ function isDirectOutboundTag(tag) {
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 = {};
@@ -450,9 +408,6 @@ function http_client_for(detour) {
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;
@@ -461,10 +416,6 @@ function http_client_for(detour) {
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;
@@ -482,11 +433,9 @@ function parse_headers(headers) {
return isEmpty(out) ? null : out;
}
/* Config helper end */
const config = {};
/* Log */
config.log = {
disabled: false,
level: log_level,
@@ -494,7 +443,6 @@ config.log = {
timestamp: true
};
/* NTP */
if (!isEmpty(ntp_server))
config.ntp = {
enabled: true,
@@ -503,8 +451,6 @@ if (!isEmpty(ntp_server))
domain_resolver: 'default-dns',
};
/* DNS start */
/* Default settings */
config.dns = {
servers: [
{
@@ -532,7 +478,6 @@ config.dns = {
};
if (!isEmpty(main_node)) {
/* Main DNS */
push(config.dns.servers, {
tag: 'main-dns',
domain_resolver: {
@@ -551,8 +496,6 @@ if (!isEmpty(main_node)) {
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],
@@ -564,18 +507,12 @@ if (!isEmpty(main_node)) {
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'],
@@ -596,7 +533,6 @@ if (!isEmpty(main_node)) {
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',
@@ -612,7 +548,6 @@ if (!isEmpty(main_node)) {
}
}
} else if (is_custom_mode) {
/* DNS servers */
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
if (cfg.enabled !== '1')
return;
@@ -640,9 +575,6 @@ if (!isEmpty(main_node)) {
});
});
/* 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')
@@ -705,10 +637,6 @@ if (!isEmpty(main_node)) {
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',
@@ -730,14 +658,11 @@ if (!isEmpty(main_node)) {
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;
@@ -749,19 +674,9 @@ if (!isEmpty(main_node)) {
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',
@@ -817,12 +732,9 @@ if (match(proxy_mode, /tun/))
udp_nat_max: udp_nat_max,
stack: tcpip_stack,
});
/* Inbound end */
/* Outbound start */
config.endpoints = [];
/* Default outbounds */
config.outbounds = [
{
type: 'direct',
@@ -835,7 +747,6 @@ config.outbounds = [
}
];
/* Main outbounds */
if (!isEmpty(main_node)) {
let urltest_nodes = [];
@@ -958,16 +869,7 @@ if (!isEmpty(main_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: [
{
@@ -983,19 +885,12 @@ config.route = {
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',
@@ -1003,14 +898,17 @@ if (!isEmpty(main_node)) {
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 (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
});
}
if (routing_mode === 'bypass_mainland_china') {
push(config.route.rules, {
action: 'resolve',
@@ -1023,20 +921,6 @@ if (!isEmpty(main_node)) {
});
}
/* 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, {
@@ -1047,8 +931,6 @@ if (!isEmpty(main_node)) {
config.route.final = 'main-out';
/* Rule set */
/* Direct list */
if (length(direct_domain_list))
push(config.route.rule_set, {
type: 'inline',
@@ -1060,7 +942,6 @@ if (!isEmpty(main_node)) {
]
});
/* Proxy list */
if (length(proxy_domain_list))
push(config.route.rule_set, {
type: 'inline',
@@ -1073,17 +954,6 @@ if (!isEmpty(main_node)) {
});
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',
@@ -1147,11 +1017,6 @@ if (!isEmpty(main_node)) {
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,
@@ -1207,8 +1072,6 @@ if (!isEmpty(main_node)) {
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, {
@@ -1219,7 +1082,6 @@ if (!isEmpty(main_node)) {
config.route.final = get_outbound(default_outbound);
/* Rule set */
uci.foreach(uciconfig, uciruleset, (cfg) => {
if (cfg.enabled !== '1')
return null;
@@ -1230,8 +1092,6 @@ if (!isEmpty(main_node)) {
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));
@@ -1247,8 +1107,6 @@ if (!isEmpty(main_node)) {
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))
@@ -1257,28 +1115,12 @@ if (!isEmpty(main_node)) {
});
}
/*
* 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: {
@@ -1288,7 +1130,6 @@ if (routing_mode in ['bypass_mainland_china', 'custom']) {
}
};
}
/* Experimental end */
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
@@ -1300,7 +1141,4 @@ if (system('/usr/bin/sing-box check --config ' + client_tmp) !== 0) {
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');
@@ -15,7 +15,6 @@ import {
removeBlankAttrs, buildTLSObject, buildTransportObject, HP_DIR, RUN_DIR
} from 'homeproxy';
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
@@ -24,11 +23,9 @@ uci.load(uciconfig);
const uciserver = 'server';
const log_level = uci.get(uciconfig, uciserver, 'log_level') || 'warn';
/* UCI config end */
const config = {};
/* Log */
config.log = {
disabled: false,
level: log_level,
@@ -42,7 +39,6 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
if (cfg.enabled !== '1')
return;
/* Snell (1.14): single-psk inbound; shape differs from the generic users[] block below */
if (cfg.type === 'snell') {
push(config.inbounds, {
type: 'snell',
@@ -76,10 +72,8 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
udp_timeout: strToTime(cfg.udp_timeout),
network: cfg.network,
/* AnyTLS */
padding_scheme: cfg.anytls_padding_scheme,
/* Hysteria (2) */
up_mbps: strToInt(cfg.hysteria_up_mbps),
down_mbps: strToInt(cfg.hysteria_down_mbps),
obfs: cfg.hysteria_obfs_type ? {
@@ -91,31 +85,25 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
ignore_client_bandwidth: strToBool(cfg.hysteria_ignore_client_bandwidth),
masquerade: cfg.hysteria_masquerade,
/* Shadowsocks */
method: (cfg.type === 'shadowsocks') ? cfg.shadowsocks_encrypt_method : null,
password: (cfg.type in ['shadowsocks', 'shadowtls']) ? cfg.password : null,
/* Tuic */
congestion_control: cfg.tuic_congestion_control,
auth_timeout: strToTime(cfg.tuic_auth_timeout),
zero_rtt_handshake: strToBool(cfg.tuic_enable_zero_rtt),
heartbeat: strToTime(cfg.tuic_heartbeat),
/* AnyTLS / HTTP / Hysteria (2) / Mixed / Socks / Trojan / Tuic / VLESS / VMess */
users: (cfg.type !== 'shadowsocks') ? [
{
name: !(cfg.type in ['http', 'mixed', 'naive', 'socks']) ? 'cfg-' + cfg['.name'] + '-server' : null,
username: cfg.username,
password: cfg.password,
/* Hysteria */
auth: (cfg.hysteria_auth_type === 'base64') ? cfg.hysteria_auth_payload : null,
auth_str: (cfg.hysteria_auth_type === 'string') ? cfg.hysteria_auth_payload : null,
/* Tuic */
uuid: cfg.uuid,
/* VLESS / VMess */
flow: cfg.vless_flow,
alterId: strToInt(cfg.vmess_alterid)
}
@@ -150,5 +138,4 @@ if (system('/usr/bin/sing-box check --config ' + server_tmp) !== 0) {
exit(1);
}
system('mv -f ' + server_tmp + ' ' + RUN_DIR + '/sing-box-s.json');
/* keep the config (key paths, user credentials) out of world-readable mode */
system('chmod 600 ' + RUN_DIR + '/sing-box-s.json');
@@ -7,13 +7,9 @@
import { mkstemp } from 'fs';
import { urldecode_params } from 'luci.http';
/* Global variables start */
export const HP_DIR = '/etc/homeproxy';
export const RUN_DIR = '/var/run/homeproxy';
/* Global variables end */
/* Utilities start */
/* Kanged from luci-app-commands */
export function shellQuote(s) {
return `'${replace(s, "'", "'\\''")}'`;
};
@@ -26,26 +22,15 @@ export function isBinary(str) {
return false;
};
/* Close a file descriptor, swallowing secondary errors (e.g. an already
closed fd). ucode has no `finally` clause, so both the normal and the
exceptional path of executeCommand() have to close explicitly. */
function closeFD(fd) {
if (fd) {
try {
fd.close();
} catch (e) {
/* already closed */
}
}
};
/*
* Read a descriptor to EOF. A single read(size) only returns up to `size`
* bytes, so the previous one-shot read(512 KiB) silently truncated larger
* responses - a subscription list above that size was cut mid-base64/mid-JSON
* and only showed up as a parse failure. MAX_OUTPUT bounds what an oversized
* or endless response can allocate.
*/
const MAX_OUTPUT = 16 * 1024 * 1024;
function readAll(fd) {
@@ -79,7 +64,6 @@ export function executeCommand(...args) {
const binary = isBinary(stdout);
/* mkstemp() returns delete-on-close files, so closing is enough */
closeFD(outfd);
closeFD(errfd);
@@ -91,8 +75,6 @@ export function executeCommand(...args) {
binary
};
} catch (e) {
/* Never leak the temporary descriptors on a failing run. ucode has
no `finally` clause, so the exception is re-raised by hand. */
closeFD(outfd);
closeFD(errfd);
@@ -114,19 +96,6 @@ export function getTime(epoch) {
};
/*
* GNU wget and OpenWrt's default /usr/bin/wget do not accept the same flags.
* On OpenWrt/ImmortalWrt that path comes from the uclient-fetch package
* (`ALTERNATIVES:=200:/usr/bin/wget:/bin/uclient-fetch`, `PROVIDES:=wget` in
* package/libs/uclient) and busybox ships no wget applet, so the effective
* wget is uclient-fetch unless wget-ssl happens to be installed on top of it.
* GNU-only flags break the fetch outright: uclient-fetch's getopt rejects
* `-nv` with "unrecognized option: n" before a single request is sent.
*
* The probe below tells the two apart. `-nv` (--no-verbose) is how GNU wget
* mutes the progress meter while still printing the failure reason on stderr;
* `-q` is the only meter switch uclient-fetch and busybox wget share.
*/
export function classifyWgetFlavor(exitcode, stdout) {
if (exitcode !== 0 || !stdout)
return 'compat';
@@ -146,13 +115,6 @@ export function wgetFlavor() {
return wget_flavor;
};
/*
* Fetch a URL and report both the body and, on failure, the reason. The
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
* caller can tell a DNS failure from a timeout or a TLS handshake error.
* Note that uclient-fetch's -q hides most failure messages, so on such a
* target the reason is often empty and only the exit code is left.
*/
export function wGETVerbose(url, ua) {
if (!url || type(url) !== 'string')
return { content: null, error: 'invalid URL' };
@@ -160,9 +122,6 @@ export function wGETVerbose(url, ua) {
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
/* -U/-T are understood by GNU wget, uclient-fetch and busybox wget alike;
* the long forms --user-agent/--timeout are not, busybox wget in
* particular knows neither of them. */
const output = executeCommand(`/usr/bin/wget ${wgetFlavor() === 'gnu' ? '-nv' : '-q'} -O- -U ${shellQuote(ua)} -T 10 ${shellQuote(url)}`) || {};
if (output.exitcode !== 0) {
let reason = trim(output.stderr || '');
@@ -180,9 +139,7 @@ export function wGETVerbose(url, ua) {
export function wGET(url, ua) {
return wGETVerbose(url, ua).content;
};
/* Utilities end */
/* String helper start */
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
@@ -199,16 +156,9 @@ export function strToTime(str) {
if (isEmpty(str))
return null;
/* Preserve values that already carry a time unit (e.g. "30s", "1m") */
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
};
/*
* Milliseconds variant of strToTime(). Used by options whose LuCI field is
* documented and validated in milliseconds while sing-box expects a duration
* (e.g. route rule tls_fragment_fallback_delay, whose default is 500ms);
* strToTime() used to turn "500" into "500s".
*/
export function strToMs(str) {
if (isEmpty(str))
return null;
@@ -255,20 +205,14 @@ export function validation(datatype, data) {
return (ret === 0);
};
/* Validate IP/CIDR format to prevent nftables template injection from resource files */
export function isValidCIDR(addr, family) {
if (isEmpty(addr))
return false;
/* Strip leading/trailing whitespace */
addr = trim(addr);
if (!addr)
return false;
/* Split address and optional prefix. The prefix has to be validated as
strictly as the address: `int()` accepts a leading number ("1.2.3.4/0 }
..." parses as 0), which would let a poisoned resource file inject
statements into the nftables template this feeds. */
const parts = split(addr, '/');
const ip = parts[0];
const prefix = parts[1];
@@ -278,31 +222,24 @@ export function isValidCIDR(addr, family) {
if (length(parts) === 2 && !match(prefix, /^\d{1,3}$/))
return false;
/* Validate IP part */
if (family === 4) {
if (!match(ip, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/))
return false;
/* Validate octet ranges */
const octets = split(ip, '.');
for (let o in octets)
if (int(o) > 255)
return false;
/* Validate prefix if present */
if (prefix && int(prefix) > 32)
return false;
} else if (family === 6) {
/* Only hex digits and colons */
if (!match(ip, /^[0-9a-fA-F:]+$/))
return false;
/* At most one "::" */
if (match(ip, /::.*::/))
return false;
/* Validate group structure */
const dcolon = index(ip, '::');
if (dcolon === -1) {
/* Uncompressed form: exactly 8 groups of 1-4 hex digits */
const groups = split(ip, ':');
if (length(groups) !== 8)
return false;
@@ -310,7 +247,6 @@ export function isValidCIDR(addr, family) {
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
return false;
} else {
/* Compressed form: "::" expands to at least one zero group */
let count = 0;
for (let part in [substr(ip, 0, dcolon), substr(ip, dcolon + 2)]) {
if (part === '')
@@ -333,9 +269,7 @@ export function isValidCIDR(addr, family) {
return true;
};
/* String helper end */
/* String parser start */
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
@@ -351,14 +285,6 @@ export function decodeBase64Str(str) {
return b64dec(str);
};
/*
* Decode %XX escapes without touching '+'. urldecode() maps '+' to a space,
* which corrupts base64 userinfo ("+" is part of the alphabet), so share-link
* userinfo is percent-decoded with this instead.
*/
/*
* ucode has no tolower(), so both hex cases are mapped by hand.
*/
function hexValue(ch) {
const lower = index('0123456789abcdef', ch);
if (lower >= 0)
@@ -369,10 +295,6 @@ function hexValue(ch) {
return upper < 0 ? -1 : 10 + upper;
}
/*
* ucode's regex engine rejects \x00 inside a character class ("Missing ']'"),
* so control bytes are checked by hand.
*/
function hasControlChar(str) {
for (let i = 0; i < length(str); i++) {
const code = ord(str, i);
@@ -449,9 +371,6 @@ export function parseURL(url) {
return null;
if (objurl.userinfo) {
/* Percent-decode first: providers emit base64 userinfo with the
padding escaped ("=" as "%3D"), which used to decode to nothing
and silently dropped method/password. */
let userinfo = percentDecode(objurl.userinfo);
userinfo = replace(userinfo, /:(.+)$/, (_, val) => {
@@ -459,14 +378,6 @@ export function parseURL(url) {
return '';
});
/*
* Keep anything without whitespace or control characters: real-world
* userinfo carries base64 (with '=' or '+'), percent-encoded tokens
* and passwords with punctuation. The previous class was written as
* [A-Za-z0-9\+\-\_\.] but ucode's regex reads \+-\_ as the range
* 0x2B-0x5F, so it accepted '=@:;[]\^<>,/' and rejected '!' - it
* neither did what it documented nor covered real passwords.
*/
if (!match(userinfo, /\s/) && !hasControlChar(userinfo))
objurl.username = userinfo;
else
@@ -483,18 +394,7 @@ export function parseURL(url) {
return objurl;
};
/* String parser end */
/* Config generator helper start */
/*
* Shared sing-box TLS object builder for the client outbound and the server
* inbound. The property order below is significant: the generated JSON keeps
* the insertion order of this object after removeBlankAttrs() drops the blank
* attributes, and both generators used to emit the fields in exactly this
* order. Keep client-only fields (insecure/handshake_timeout/utls) and
* server-only fields (key_path/certificate_provider) at their current
* positions when editing.
*/
export function buildTLSObject(cfg, is_server) {
if (cfg.tls !== '1')
return null;
@@ -540,7 +440,6 @@ export function buildTLSObject(cfg, is_server) {
ech: is_server ? (cfg.tls_ech_key ? {
enabled: true,
key: split(cfg.tls_ech_key, '\n')
/* config: split(cfg.tls_ech_config, '\n') */
} : null) : ((cfg.tls_ech === '1') ? {
enabled: true,
config: cfg.tls_ech_config,
@@ -567,8 +466,6 @@ export function buildTLSObject(cfg, is_server) {
};
};
/* Shared sing-box transport object builder; the client transport additionally
supports the gRPC keepalive hint, the server one does not. */
export function buildTransportObject(cfg, is_server) {
if (isEmpty(cfg.transport))
return null;
@@ -589,15 +486,7 @@ export function buildTransportObject(cfg, is_server) {
permit_without_stream: is_server ? null : strToBool(cfg.grpc_permit_without_stream)
};
};
/* Config generator helper end */
/* PEM validation start */
/*
* Check that `content` is a PEM certificate (is_private_key = false) or an
* RSA/EC private key (is_private_key = true): matching BEGIN/END boundaries
* with a base64 body in between. Kanged from luci-proto-openconnect; used by
* the rpcd certificate upload and available for future certificate features.
*/
export function isValidPEM(content, is_private_key) {
if (isEmpty(content))
return false;
@@ -619,4 +508,3 @@ export function isValidPEM(content, is_private_key) {
return true;
};
/* PEM validation end */
@@ -27,11 +27,9 @@ const uciinfra = 'infra',
uciroutingrule = 'routing_rule',
uciserver = 'server';
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
/* chinadns server now only accepts single server */
const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
@@ -42,51 +40,41 @@ if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
}
/* github_token option has been moved to config section */
const github_token = uci.get(uciconfig, uciinfra, 'github_token');
if (github_token) {
uci.set(uciconfig, ucimain, 'github_token', github_token);
uci.delete(uciconfig, uciinfra, 'github_token')
}
/* ntp_server was introduced */
if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
uci.set(uciconfig, uciinfra, 'ntp_server', 'nil');
/* tun_gso was deprecated in sb 1.11 */
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
uci.delete(uciconfig, uciinfra, 'tun_gso');
/* create migration section */
if (!uci.get(uciconfig, ucimigration))
uci.set(uciconfig, ucimigration, uciconfig);
/* delete old crontab command */
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
if (!migration_crontab) {
system('sed -i "/update_crond.sh/d" "/etc/crontabs/root" 2>/dev/null');
uci.set(uciconfig, ucimigration, 'crontab', '1');
}
/* log_level was introduced */
if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
uci.set(uciconfig, ucimain, 'log_level', 'warn');
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
/* experimental section was removed */
if (uci.get(uciconfig, 'experimental'))
uci.delete(uciconfig, 'experimental');
/* block-dns was removed from built-in dns servers */
const default_dns_server = uci.get(uciconfig, ucidns, 'default_server');
if (default_dns_server === 'block-dns') {
/* append a rule at last to block all DNS queries */
uci.set(uciconfig, '_migration_dns_final_block', ucidnsrule);
uci.set(uciconfig, '_migration_dns_final_block', 'label', 'migration_final_block_dns');
uci.set(uciconfig, '_migration_dns_final_block', 'enabled', '1');
@@ -98,12 +86,9 @@ else if (default_dns_server === 'local-dns')
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
const dns_server_migration = {};
/* DNS servers options */
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
/* legacy format was deprecated in sb 1.12 */
if (cfg.address) {
const addr = parseURL((!match(cfg.address, /:\/\//) ? 'udp://' : '') + (validation('ip6addr', cfg.address) ? `[${cfg.address}]` : cfg.address));
/* RCode was moved into DNS rules */
if (addr.protocol === 'rcode') {
dns_server_migration[cfg['.name']] = { action: 'predefined' };
switch (addr.hostname) {
@@ -156,9 +141,7 @@ uci.foreach(uciconfig, ucidnsserver, (cfg) => {
}
});
/* DNS rules options */
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
/* outbound was removed in sb 1.12 */
if (cfg.outbound) {
uci.delete(uciconfig, cfg['.name']);
if (!cfg.enabled)
@@ -181,20 +164,16 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
return;
}
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-dns was moved into action in sb 1.11 */
if (cfg.server === 'block-dns') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'server');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
/* strategy and client_subnet were moved into dns rules */
if (dns_server_migration[cfg.server]) {
if (dns_server_migration[cfg.server].strategy)
uci.set(uciconfig, cfg['.name'], 'strategy', dns_server_migration[cfg.server].strategy);
@@ -210,26 +189,20 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
}
});
/* nodes options */
uci.foreach(uciconfig, ucinode, (cfg) => {
/* tls_ech_tls_disable_drs is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_tls_disable_drs))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_tls_disable_drs');
/* tls_ech_enable_pqss is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_enable_pqss))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_enable_pqss');
/* wireguard_gso was deprecated in sb 1.11 */
if (!isEmpty(cfg.wireguard_gso))
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
/* hysteria_revc_window was a typo, renamed to hysteria_recv_window */
if (!isEmpty(cfg.hysteria_revc_window))
uci.rename(uciconfig, cfg['.name'], 'hysteria_revc_window', 'hysteria_recv_window');
});
/* sing-box 1.14: remove deprecated DNS semantics */
if (!isEmpty(uci.get(uciconfig, ucidns, 'independent_cache')))
uci.delete(uciconfig, ucidns, 'independent_cache');
@@ -248,38 +221,29 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
uci.delete(uciconfig, cfg['.name'], 'rule_set_ip_cidr_accept_empty');
});
/* routing rules options */
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-out was moved into action in sb 1.11 */
if (cfg.outbound === 'block-out') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'outbound');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
});
/* server options */
/* auto_firewall was moved into server options */
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
if (!isEmpty(auto_firewall))
uci.delete(uciconfig, uciserver, 'auto_firewall');
uci.foreach(uciconfig, uciserver, (cfg) => {
/* auto_firewall was moved into server options */
if (auto_firewall === '1')
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
/* sniff_override was deprecated in sb 1.11 */
if (!isEmpty(cfg.sniff_override))
uci.delete(uciconfig, cfg['.name'], 'sniff_override');
/* domain_strategy is now pointless without sniff override */
if (!isEmpty(cfg.domain_strategy))
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
});
@@ -10,28 +10,13 @@ import { urldecode, urlencode } from 'luci.http';
import { decodeBase64Str, parseURL, percentDecode, isEmpty, validation } from 'homeproxy';
/*
* Share-link parsers. Every protocol has its own parse_<scheme>_uri() so the
* branches can be read, changed and unit-tested in isolation; parse_uri() only
* dispatches on the scheme and applies the common address/port validation and
* label fallback afterwards.
*
* `features` is the sing-box feature map (with_quic/with_utls/...), `log` is a
* logging callback, both injected so the parsers stay free of ubus/uci.
*/
/*
* Transports sing-box accepts on a V2Ray-style outbound. Anything else
* (xhttp, splithttp, ...) makes sing-box reject the whole generated config,
* so such a node is dropped while importing instead of poisoning every node.
*/
const SUPPORTED_TRANSPORTS = ['ws', 'http', 'grpc', 'httpupgrade', 'quic'];
function unsupported_transport(config) {
return !isEmpty(config.transport) && !(config.transport in SUPPORTED_TRANSPORTS);
}
/* https://shadowsocks.org/guide/sip008.html */
export function parse_sip008_uri(uri) {
return {
label: uri.remarks,
@@ -45,7 +30,6 @@ export function parse_sip008_uri(uri) {
};
};
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
export function parse_anytls_uri(uri) {
uri = split(trim(uri), '://');
@@ -80,7 +64,6 @@ export function parse_http_uri(uri) {
};
};
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
export function parse_hysteria_uri(uri, features, log) {
uri = split(trim(uri), '://');
@@ -113,7 +96,6 @@ export function parse_hysteria_uri(uri, features, log) {
};
};
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
export function parse_hysteria2_uri(uri, features, log) {
uri = split(trim(uri), '://');
@@ -142,7 +124,6 @@ export function parse_hysteria2_uri(uri, features, log) {
};
};
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
export function parse_snell_uri(uri) {
uri = split(trim(uri), '://');
@@ -183,7 +164,6 @@ export function parse_socks_uri(uri) {
export function parse_ss_uri(uri) {
uri = split(trim(uri), '://');
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
@@ -194,18 +174,13 @@ export function parse_ss_uri(uri) {
uri[1] = ss_decoded + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
const url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = [];
if (url.username && url.password)
/* User info encoded with URIComponent; parseURL() already decoded it */
ss_userinfo = [url.username, url.password];
else if (url.username)
/* User info encoded with base64 (padding may have arrived as %3D) */
ss_userinfo = split(decodeBase64Str(url.username) ?? '', ':', 2);
let ss_plugin, ss_plugin_opts;
@@ -213,7 +188,6 @@ export function parse_ss_uri(uri) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
@@ -230,7 +204,6 @@ export function parse_ss_uri(uri) {
};
};
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
export function parse_trojan_uri(uri) {
uri = split(trim(uri), '://');
@@ -270,7 +243,6 @@ export function parse_trojan_uri(uri) {
return config;
};
/* https://github.com/daeuniverse/dae/discussions/182 */
export function parse_tuic_uri(uri, features, log) {
uri = split(trim(uri), '://');
@@ -299,14 +271,12 @@ export function parse_tuic_uri(uri, features, log) {
};
};
/* https://github.com/XTLS/Xray-core/discussions/716 */
export function parse_vless_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
@@ -332,10 +302,6 @@ export function parse_vless_uri(uri, features, log) {
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: features.with_utls ? params.fp : null,
/* sing-box rejects every other value ("unsupported flow") and a single
rejected outbound makes the whole generated config fail, so only the
supported value is imported (the common
"xtls-rprx-vision-udp443" becomes "no flow" instead of breaking). */
vless_flow: (params.security in ['tls', 'reality'] && params.flow === 'xtls-rprx-vision') ? params.flow : null
};
switch (params.type) {
@@ -372,11 +338,9 @@ export function parse_vless_uri(uri, features, log) {
return config;
};
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
export function parse_vmess_uri(uri, features, log) {
uri = split(trim(uri), '://');
/* "Lovely" shadowrocket format */
if (match(uri, /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
@@ -393,7 +357,6 @@ export function parse_vmess_uri(uri, features, log) {
if (payload.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (payload.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
return null;
@@ -404,13 +367,6 @@ export function parse_vmess_uri(uri, features, log) {
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (payload.aid && int(payload.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
* return null;
* }
*/
const config = {
label: payload.ps ? urldecode(payload.ps) : null,
@@ -5,21 +5,12 @@
SCRIPTS_DIR="/etc/homeproxy/scripts"
# update_resources.sh exit codes: 0 updated, 1 failed, 2 another run holds the
# lock, 3 already up to date.
changed=0
for i in "china_ip4" "china_ip6" "gfw_list" "china_list" "geoip_cn" "geosite_cn"; do
"$SCRIPTS_DIR"/update_resources.sh "$i"
[ "$?" -eq 0 ] && changed=1
done
# The nft sets (china_ip4/ip6.txt) and the dnsmasq snippets (gfw_list.txt,
# china_list.txt) are rendered from those files, and the client rule-sets are
# loaded from the two .srs files, so a downloaded list only takes effect after
# they have been regenerated. Without this the daily cron update rewrote the
# files on disk and nothing else.
[ "$changed" -eq 0 ] || /etc/init.d/homeproxy refresh_lists
# update_subscriptions.uc restarts the service itself when it changed the UCI
# configuration.
"$SCRIPTS_DIR"/update_subscriptions.uc
@@ -35,12 +35,6 @@ check_list_update() {
return 2
fi
# --header= is the only header switch understood by all three wget
# implementations this runs on (uclient-fetch, GNU wget, busybox wget);
# the previous --header-file= is rejected by uclient-fetch and GNU wget
# alike, which made every version probe fail whenever a GitHub token was
# configured. The token is therefore visible in `ps` for the duration of
# the request; that is the trade-off for a single code path.
local api_url="https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1"
local list_info
if [ -n "$github_token" ]; then
@@ -20,7 +20,6 @@ import {
import { parse_uri } from 'parse_uri';
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
@@ -44,9 +43,7 @@ if (routing_mode !== 'custom') {
main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil';
main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil';
}
/* UCI config end */
/* String helper start */
function filter_check(name) {
if (isEmpty(name) || filter_mode === 'disabled' || isEmpty(filter_keywords))
return false;
@@ -68,9 +65,7 @@ function filter_check(name) {
return ret;
}
/* String helper end */
/* Common var start */
const node_cache = {},
node_result = [];
@@ -78,9 +73,7 @@ const ubus = connect();
const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {};
if (isEmpty(sing_features))
log('Warning: Failed to query sing-box features via ubus, assuming defaults.');
/* Common var end */
/* Log */
system(`mkdir -p ${RUN_DIR}`);
function log(...args) {
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
@@ -88,20 +81,10 @@ function log(...args) {
logfile.close();
}
/*
* Subscription URLs usually carry the provider token in the query string;
* the log is surfaced in LuCI, so keep it out.
*/
function redact(url) {
return replace(url, /[?#].*$/, '');
}
/*
* mkdir is the lock: atomic everywhere this runs and no flock binding needed.
* A lock older than LOCK_STALE is broken, so a killed run cannot block every
* later update. Two concurrent runs (cron + the LuCI button) used to
* interleave commits and service restarts.
*/
const LOCK_DIR = RUN_DIR + '/update_subscriptions.lock';
const LOCK_STALE = 600;
@@ -126,11 +109,6 @@ function release_lock() {
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
}
/*
* sing-box rejects the whole generated config over one malformed node, and the
* generator keeps the old config when it fails, so a node that cannot possibly
* work must not reach UCI. Returns the reason, or null when the node is usable.
*/
function node_problem(cfg) {
if (isEmpty(cfg.address) || isEmpty(cfg.port))
return 'missing address or port';
@@ -199,12 +177,9 @@ function main() {
try {
nodes = json(res).servers || json(res);
/* Shadowsocks SIP008 format */
if (nodes[0].server && nodes[0].method)
map(nodes, (_, i) => nodes[i].nodetype = 'sip008');
} catch(e) {
/* Clash/mihomo subscriptions are YAML, not base64; say so instead
of reporting a base64/JSON failure followed by "no valid node". */
if (match(res, /(^|\n)[ \t]*(proxies|proxy-groups)[ \t]*:/)) {
log(sprintf('Unsupported subscription format (Clash/mihomo YAML) at %s.', shown_url));
continue;
@@ -222,9 +197,6 @@ function main() {
const config = parse_uri(node, sing_features, log);
if (isEmpty(config)) {
/* No credentials in the log: only the scheme says whether a
parser is missing or the entry is simply not a node. Only
the first few are printed, the rest is summarized below. */
if (skipped < 5)
log(sprintf('Skipping unparsable entry (%s) from %s.',
type(node) === 'string' ? replace(node, /:.*$/, '') : type(node), shown_url));
@@ -288,11 +260,9 @@ function main() {
let added = 0, removed = 0, changed = false;
uci.foreach(uciconfig, ucinode, (cfg) => {
/* Nodes created by the user */
if (!cfg.grouphash)
return null;
/* Empty object - failed to fetch nodes, or subscription URL not yet processed */
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
return null;
@@ -304,14 +274,10 @@ function main() {
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
} else {
map(keys(cfg), (v) => {
/* skip the cursor's own metadata (.name/.type/...): deleting
those is a no-op and would mark every run as changed */
if (substr(v, 0, 1) === '.')
return null;
if (v in node_cache[cfg.grouphash][cfg['.name']]) {
/* only write what actually differs, so an unchanged
subscription does not fake a change below */
const fresh = node_cache[cfg.grouphash][cfg['.name']][v];
const current = uci.get(uciconfig, cfg['.name'], v);
if (type(fresh) === 'array' ? (sprintf('%J', uci.get_all(uciconfig, cfg['.name'], v) || []) !== sprintf('%J', fresh))
@@ -342,9 +308,6 @@ function main() {
});
uci.commit(uciconfig);
/* via_proxy keeps the service up during the fetch, but a configuration
change still has to be applied - it used to be committed and then
ignored until the next restart. */
let need_restart = (via_proxy !== '1') || changed;
if (!isEmpty(main_node)) {
const first_server = uci.get_first(uciconfig, ucinode);
@@ -410,7 +373,6 @@ function main() {
}
}
/* Scrub stale urltest member references in custom routing nodes */
uci.foreach(uciconfig, 'routing_node', (cfg) => {
if (cfg.node !== 'urltest' || isEmpty(cfg.urltest_nodes))
return null;