⛅ Sync 2026-09-29 21:45:00

This commit is contained in:
github-actions[bot] committed 2026-09-29 21:45:00 +08:00
1 parent a4d6e3e4b9
commit da77daf88e
191 files changed
+8077 -6247

No files matched your search

@@ -76,6 +76,61 @@ function resolve_mark(str) {
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)} xor ${fw4.hex(mark.mark)}`;
}
/*
* Everything below is interpolated into an nftables script. fw4 loads its
* includes in one atomic transaction, so a single unparseable value does not
* just break this rule: every later `fw4 reload` fails and the whole firewall
* stops being updated. The UI validates its fields, but the values can also
* come from `uci set`, so each one is checked here as well and dropped when it
* is not the type the rule expects.
*/
const iface_re = /^[A-Za-z0-9._-]{1,15}$/;
const mac_re = /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/;
function filter_iface(list, fallback) {
if (isEmpty(list))
return list;
let out = [];
for (let i in list)
if (match(trim(i), iface_re))
push(out, trim(i));
if (isEmpty(out) && !isEmpty(fallback))
push(out, fallback);
return out;
}
function filter_mac(list) {
if (isEmpty(list))
return list;
let out = [];
for (let m in list)
if (match(trim(m), mac_re))
push(out, trim(m));
return out;
}
function filter_ipv4(list) {
if (isEmpty(list))
return list;
let out = [];
for (let ip in list)
if (isValidCIDR(ip, 4))
push(out, trim(ip));
return out;
}
function resolve_port(str, fallback) {
const port = trim(str ?? '');
return match(port, /^\d{1,5}$/) && int(port) > 0 && int(port) <= 65535 ? port : fallback;
}
/* Misc config */
const resources_dir = '/etc/homeproxy/resources';
@@ -100,6 +155,19 @@ let routing_port = uci.get(cfgname, 'config', 'routing_port');
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
/* the port list is interpolated into an nftables set: keep only real ports,
and fall back to "no list" (the guards below then skip the set and its
rules) when nothing survives */
if (routing_port) {
let ports = [];
for (let port in split(routing_port, ',')) {
const p = trim(port);
if (match(p, /^\d{1,5}$/) && int(p) > 0 && int(p) <= 65535)
push(ports, p);
}
routing_port = length(ports) ? join(',', ports) : null;
}
const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy',
ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0';
@@ -108,16 +176,19 @@ let self_mark, redirect_port,
tun_name, tun_mark;
if (match(proxy_mode, /redirect/)) {
self_mark = uci.get(cfgname, 'infra', 'self_mark') || '100';
redirect_port = uci.get(cfgname, 'infra', 'redirect_port') || '5331';
/* print as a valid mark expression; a hand-edited "value/mask" would
otherwise reach nft verbatim and break the whole file */
self_mark = resolve_mark(uci.get(cfgname, 'infra', 'self_mark')) || resolve_mark('100');
redirect_port = resolve_port(uci.get(cfgname, 'infra', 'redirect_port'), '5331');
}
if (match(proxy_mode, /tproxy/))
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
tproxy_port = uci.get(cfgname, 'infra', 'tproxy_port') || '5332';
tproxy_port = resolve_port(uci.get(cfgname, 'infra', 'tproxy_port'), '5332');
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101');
}
if (match(proxy_mode, /tun/)) {
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
const tun_name_raw = trim(uci.get(cfgname, 'infra', 'tun_name') ?? '');
tun_name = match(tun_name_raw, iface_re) ? tun_name_raw : 'singtun0';
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102');
}
@@ -132,12 +203,26 @@ const control_options = [
];
const control_info = {};
for (let i in control_options)
control_info[i] = uci.get(cfgname, 'control', i);
for (let i in control_options) {
const raw = uci.get(cfgname, 'control', i);
if (match(i, /_mac_addrs$/))
control_info[i] = filter_mac(raw);
else if (match(i, /_ipv4_ips$/))
control_info[i] = filter_ipv4(raw);
else if (i === 'listen_interfaces')
/* fail closed: a configured but unusable interface list must not turn
into "every interface" (that is exactly what the option restricts) */
control_info[i] = filter_iface(raw, '__hp_invalid_iface__');
else
control_info[i] = raw;
}
const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1';
/* dnsmasq's port, not sing-box's dns_port (the hijack hands the query to
dnsmasq, which forwards to the sing-box listener itself) */
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
dns_port = uci.get('dhcp', '@dnsmasq[0]', 'port') || '53';
dnsmasq_port = resolve_port(uci.get('dhcp', '@dnsmasq[0]', 'port'), '53');
/* UCI config end */
-%}
@@ -288,7 +373,7 @@ chain dstnat {
{% if (control_info.listen_interfaces): %}
meta iifname {{ array_to_nftarr(control_info.listen_interfaces) }}
{%- endif /* listen_interfaces */ %}
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dns_port }} comment "!{{ cfgname }}: DNS hijack"
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack"
{% endif /* dns_hijacked */ %}
{% if (match(proxy_mode, /redirect/)): %}
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
@@ -20,8 +20,12 @@ if (match(proxy_mode, /tun/)) {
else
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node !== 'nil')
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
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';
}
}
const server_enabled = uci.get(cfgname, 'server', 'enabled');
@@ -7,13 +7,13 @@
'use strict';
import { readfile, writefile } from 'fs';
import { access, readfile, writefile } from 'fs';
import { isnan } from 'math';
import { connect } from 'ubus';
import { cursor } from 'uci';
import {
isEmpty, parseURL, strToBool, strToInt, strToTime,
isEmpty, parseURL, strToBool, strToInt, strToTime, strToMs,
removeBlankAttrs, validation, buildTLSObject, buildTransportObject,
HP_DIR, RUN_DIR
} from 'homeproxy';
@@ -418,6 +418,57 @@ function isDirectOutboundTag(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 = {};
@@ -487,18 +538,22 @@ 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 */
push(config.dns.rules, {
query_type: [64, 65],
action: 'reject'
});
/* 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',
strategy: 'prefer_ipv6'
/* 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)
@@ -559,7 +614,7 @@ if (!isEmpty(main_node)) {
server: cfg.server,
server_port: strToInt(cfg.server_port),
path: cfg.path,
headers: cfg.headers,
headers: parse_headers(cfg.headers),
tls: cfg.tls_sni ? {
enabled: true,
server_name: cfg.tls_sni
@@ -668,6 +723,13 @@ if (!isEmpty(main_node)) {
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;
@@ -679,10 +741,18 @@ if (!isEmpty(main_node)) {
/* 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: '::',
listen: '127.0.0.1',
listen_port: int(dns_port)
});
@@ -986,36 +1056,48 @@ if (!isEmpty(main_node)) {
if (routing_mode === 'bypass_mainland_china') {
/*
* Fetched straight from the upstream SagerNet repositories and
* downloaded through the selected node. A direct fetch depends on
* the CDN staying reachable from mainland China, where DNS pollution
* makes it fail intermittently; the three files total ~250 KB per
* day, so proxying the download costs almost nothing.
* 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.
*/
push(config.route.rule_set, {
type: 'remote',
tag: 'geoip-cn',
format: 'binary',
url: 'https://fastly.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
push(config.route.rule_set, {
type: 'remote',
tag: 'geosite-cn',
format: 'binary',
url: 'https://fastly.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-geolocation-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
push(config.route.rule_set, {
type: 'remote',
tag: 'geosite-noncn',
format: 'binary',
url: 'https://fastly.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-geolocation-!cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
const preset_rulesets = [
{
tag: 'geoip-cn',
path: HP_DIR + '/resources/geoip_cn.srs',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs'
},
{
tag: 'geosite-cn',
path: HP_DIR + '/resources/geosite_cn.srs',
url: '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))
@@ -1084,7 +1166,7 @@ if (!isEmpty(main_node)) {
udp_connect: strToBool(cfg.udp_connect),
udp_timeout: strToTime(cfg.udp_timeout),
tls_fragment: strToBool(cfg.tls_fragment),
tls_fragment_fallback_delay: strToTime(cfg.tls_fragment_fallback_delay),
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,
@@ -1147,48 +1229,18 @@ if (!isEmpty(main_node)) {
url: cfg.url,
update_interval: cfg.update_interval
};
/* download_detour is a pre-1.14 option that only makes sense for
remote rule-sets; emitting it for local/inline ones makes sing-box
1.14 reject the whole config. It is translated into http_clients
right below. */
/* sing-box 1.14 downloads remote rule-sets through a top-level
http_client; download_detour no longer exists. */
if (cfg.type === 'remote')
ruleset.download_detour = get_outbound(cfg.outbound) || get_outbound(default_outbound);
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);
});
}
/* sing-box 1.14: remote rule-sets download via top-level http_clients;
replaces the legacy download_detour field everywhere (preset + custom). */
const http_clients = [];
const http_seen = {};
for (let rs in (config.route.rule_set || [])) {
/* Strip the legacy field from every entry first, including the
local/inline ones: sing-box 1.14 rejects it everywhere except on
remote rule-sets, where it is replaced by http_client below. */
let detour = rs.download_detour;
delete rs.download_detour;
if (rs.type !== 'remote')
continue;
if (isEmpty(detour))
detour = (routing_mode === 'custom') ? (get_outbound(default_outbound) || 'direct-out') : 'direct-out';
const tag = 'hp-' + detour;
rs.http_client = tag;
if (!http_seen[detour]) {
http_seen[detour] = true;
/* sing-box 1.14 rejects detouring to an empty direct outbound
(pure TUN mode has no self_mark on direct-out). Omitting detour
uses the same system direct dialer, so behavior is unchanged. */
const client = { tag: tag };
if (!(isEmpty(self_mark) && isDirectOutboundTag(detour)))
client.detour = detour;
push(http_clients, client);
}
}
/* 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 */
@@ -1215,3 +1267,7 @@ 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');
@@ -150,3 +150,5 @@ 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');
@@ -39,6 +39,29 @@ function closeFD(fd) {
}
};
/*
* 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) {
let out = '';
for (;;) {
const chunk = fd.read(64 * 1024) ?? '';
if (length(chunk) === 0)
break;
out += chunk;
if (length(out) >= MAX_OUTPUT)
break;
}
return out;
};
export function executeCommand(...args) {
let outfd = null, errfd = null;
@@ -51,8 +74,8 @@ export function executeCommand(...args) {
outfd.seek(0);
errfd.seek(0);
const stdout = outfd.read(1024 * 512) ?? '';
const stderr = errfd.read(1024 * 512) ?? '';
const stdout = readAll(outfd);
const stderr = readAll(errfd);
const binary = isBinary(stdout);
@@ -180,6 +203,19 @@ export function strToTime(str) {
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;
return match(str, /[a-zA-Z]$/) ? str : (str + 'ms');
};
export function removeBlankAttrs(res) {
let content;
@@ -229,11 +265,19 @@ export function isValidCIDR(addr, family) {
if (!addr)
return false;
/* Split address and optional prefix */
/* 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];
if (length(parts) > 2)
return false;
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}$/))
@@ -244,7 +288,7 @@ export function isValidCIDR(addr, family) {
if (int(o) > 255)
return false;
/* Validate prefix if present */
if (prefix && (int(prefix) < 0 || int(prefix) > 32))
if (prefix && int(prefix) > 32)
return false;
} else if (family === 6) {
/* Only hex digits and colons */
@@ -281,7 +325,7 @@ export function isValidCIDR(addr, family) {
return false;
}
if (prefix && (int(prefix) < 0 || int(prefix) > 128))
if (prefix && int(prefix) > 128)
return false;
} else {
return false;
@@ -307,6 +351,50 @@ 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)
return lower;
const upper = index('ABCDEF', 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);
if (code < 0x20 || code === 0x7f)
return true;
}
return false;
}
export function percentDecode(str) {
if (isEmpty(str))
return str;
return replace(str, /%([0-9A-Fa-f]{2})/g, (whole, hex) => {
const hi = hexValue(substr(hex, 0, 1));
const lo = hexValue(substr(hex, 1, 1));
return (hi < 0 || lo < 0) ? whole : chr(hi * 16 + lo);
});
};
export function parseURL(url) {
if (type(url) !== 'string')
return null;
@@ -361,18 +449,30 @@ export function parseURL(url) {
return null;
if (objurl.userinfo) {
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
/* 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) => {
objurl.password = val;
return '';
});
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
objurl.username = objurl.userinfo;
delete objurl.userinfo;
} else {
delete objurl.userinfo;
/*
* 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
delete objurl.password;
}
delete objurl.userinfo;
};
if (!objurl.port)
@@ -8,7 +8,7 @@
import { urldecode, urlencode } from 'luci.http';
import { decodeBase64Str, parseURL, isEmpty, validation } from 'homeproxy';
import { decodeBase64Str, parseURL, percentDecode, isEmpty, validation } from 'homeproxy';
/*
* Share-link parsers. Every protocol has its own parse_<scheme>_uri() so the
@@ -20,6 +20,17 @@ import { decodeBase64Str, parseURL, isEmpty, validation } from 'homeproxy';
* 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 {
@@ -178,8 +189,9 @@ export function parse_ss_uri(uri) {
if (length(ss_suri) <= 2) {
if (length(ss_suri) === 2)
ss_slabel = '#' + urlencode(ss_suri[1]);
if (decodeBase64Str(ss_suri[0]))
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
const ss_decoded = decodeBase64Str(percentDecode(ss_suri[0]));
if (ss_decoded)
uri[1] = ss_decoded + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
@@ -188,13 +200,13 @@ export function parse_ss_uri(uri) {
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
const url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = {};
let ss_userinfo = [];
if (url.username && url.password)
/* User info encoded with URIComponent */
ss_userinfo = [url.username, urldecode(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 */
ss_userinfo = split(decodeBase64Str(urldecode(url.username)), ':', 2);
/* User info encoded with base64 (padding may have arrived as %3D) */
ss_userinfo = split(decodeBase64Str(url.username) ?? '', ':', 2);
let ss_plugin, ss_plugin_opts;
if (url.search && url.searchParams.plugin) {
@@ -250,6 +262,11 @@ export function parse_trojan_uri(uri) {
break;
}
if (unsupported_transport(config)) {
log(sprintf('Skipping unsupported %s transport node: %s.', config.transport, config.label || config.address));
return null;
}
return config;
};
@@ -315,7 +332,11 @@ 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,
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : 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) {
case 'grpc':
@@ -343,6 +364,11 @@ export function parse_vless_uri(uri, features, log) {
break;
}
if (unsupported_transport(config)) {
log(sprintf('Skipping unsupported %s transport node: %s.', config.transport, config.label || config.address));
return null;
}
return config;
};
@@ -428,6 +454,11 @@ export function parse_vmess_uri(uri, features, log) {
break;
}
if (unsupported_transport(config)) {
log(sprintf('Skipping unsupported %s transport node: %s.', config.transport, config.label || config.address));
return null;
}
return config;
};
@@ -5,8 +5,21 @@
SCRIPTS_DIR="/etc/homeproxy/scripts"
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
# 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,19 +35,21 @@ check_list_update() {
return 2
fi
local github_header_file=""
# --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
github_header_file="$RUN_DIR/.gh_header_${listtype}"
( umask 077; printf 'Authorization: Bearer %s\n' "$github_token" > "$github_header_file" )
trap "[ -n \"$github_header_file\" ] && rm -f \"$github_header_file\"" EXIT INT TERM
list_info="$($wget --header="Authorization: Bearer $github_token" -O- "$api_url")"
else
list_info="$($wget -O- "$api_url")"
fi
local list_info="$($wget ${github_header_file:+--header-file=$github_header_file} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
local wget_exit=$?
[ -n "$github_header_file" ] && rm -f "$github_header_file"
trap - EXIT INT TERM
if [ $wget_exit -ne 0 ]; then
log "[$(to_upper "$listtype")] Failed to fetch version info (wget exit $wget_exit)."
return 1
@@ -105,8 +107,14 @@ case "$1" in
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
sed -i -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt"
;;
"geoip_cn")
check_list_update "$1" "SagerNet/sing-geoip" "rule-set" "geoip-cn.srs"
;;
"geosite_cn")
check_list_update "$1" "SagerNet/sing-geosite" "rule-set" "geosite-geolocation-cn.srs"
;;
*)
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list / geoip_cn / geosite_cn>"
exit 1
;;
esac
@@ -8,14 +8,14 @@
'use strict';
import { md5 } from 'digest';
import { open } from 'fs';
import { lstat, open } from 'fs';
import { connect } from 'ubus';
import { cursor } from 'uci';
import { init_action } from 'luci.sys';
import {
wGETVerbose, decodeBase64Str, getTime, isEmpty, HP_DIR, RUN_DIR
wGETVerbose, decodeBase64Str, getTime, isEmpty, shellQuote, HP_DIR, RUN_DIR
} from 'homeproxy';
import { parse_uri } from 'parse_uri';
@@ -88,6 +88,94 @@ 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;
function acquire_lock() {
if (system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0)
return true;
const st = lstat(LOCK_DIR);
const age = st ? max(0, time() - (st.mtime || 0)) : null;
if (age != null && age > LOCK_STALE) {
log(sprintf('Breaking a stale update lock (%s seconds old).', age));
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
return system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0;
}
return false;
}
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';
if (!validation_port(cfg.port))
return 'invalid port';
switch (cfg.type) {
case 'shadowsocks':
if (isEmpty(cfg.shadowsocks_encrypt_method) || isEmpty(cfg.password))
return 'missing shadowsocks method or password';
break;
case 'vless':
case 'vmess':
if (isEmpty(cfg.uuid))
return 'missing uuid';
break;
case 'trojan':
case 'anytls':
case 'snell':
if (isEmpty(cfg.password))
return 'missing password';
break;
case 'tuic':
if (isEmpty(cfg.uuid) || isEmpty(cfg.password))
return 'missing uuid or password';
break;
case 'hysteria':
case 'hysteria2':
if (isEmpty(cfg.password) && isEmpty(cfg.hysteria_auth_payload))
return 'missing auth';
break;
case 'socks':
case 'http':
break;
default:
return 'unsupported type';
}
return null;
}
function validation_port(port) {
return match(port, /^\d{1,5}$/) != null && int(port) > 0 && int(port) <= 65535;
}
function main() {
if (via_proxy !== '1') {
log('Stopping service...');
@@ -98,10 +186,11 @@ function main() {
url = replace(url, /#.*$/, '');
const groupHash = md5(url);
node_cache[groupHash] = {};
const shown_url = redact(url);
const fetched = wGETVerbose(url, user_agent);
if (isEmpty(fetched.content)) {
log(sprintf('Failed to fetch resources from %s: %s', url, fetched.error || 'empty response'));
log(sprintf('Failed to fetch resources from %s: %s', shown_url, fetched.error || 'empty response'));
continue;
}
const res = fetched.content;
@@ -114,19 +203,43 @@ function main() {
if (nodes[0].server && nodes[0].method)
map(nodes, (_, i) => nodes[i].nodetype = 'sip008');
} catch(e) {
log(sprintf('JSON parse failed for %s, trying base64: %s', url, e.message));
/* 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;
}
log(sprintf('JSON parse failed for %s, trying base64: %s', shown_url, e.message));
nodes = decodeBase64Str(res);
nodes = nodes ? split(trim(nodes), '\n') : [];
}
let count = 0;
let count = 0, skipped = 0;
for (let node in nodes) {
let config;
if (!isEmpty(node))
config = parse_uri(node, sing_features, log);
if (isEmpty(config))
if (isEmpty(node))
continue;
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));
skipped++;
continue;
}
const problem = node_problem(config);
if (problem) {
if (skipped < 5)
log(sprintf('Skipping invalid node %s: %s.', config.label || config.address, problem));
skipped++;
continue;
}
const label = config.label;
config.label = null;
const confHash = md5(sprintf('%J', config)),
@@ -153,10 +266,13 @@ function main() {
}
}
if (skipped > 0)
log(sprintf('%s entries skipped from %s.', skipped, shown_url));
if (count === 0)
log(sprintf('No valid node found in %s.', url));
log(sprintf('No valid node found in %s.', shown_url));
else
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), url));
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), shown_url));
}
if (isEmpty(node_result)) {
@@ -170,7 +286,7 @@ function main() {
return false;
}
let added = 0, removed = 0;
let added = 0, removed = 0, changed = false;
uci.foreach(uciconfig, ucinode, (cfg) => {
/* Nodes created by the user */
if (!cfg.grouphash)
@@ -183,14 +299,30 @@ function main() {
if (!node_cache[cfg.grouphash][cfg['.name']]) {
uci.delete(uciconfig, cfg['.name']);
removed++;
changed = true;
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
} else {
map(keys(cfg), (v) => {
if (v in node_cache[cfg.grouphash][cfg['.name']])
uci.set(uciconfig, cfg['.name'], v, node_cache[cfg.grouphash][cfg['.name']][v]);
else
/* 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))
: (current !== sprintf('%s', fresh))) {
uci.set(uciconfig, cfg['.name'], v, fresh);
changed = true;
}
} else {
uci.delete(uciconfig, cfg['.name'], v);
changed = true;
}
});
node_cache[cfg.grouphash][cfg['.name']].isExisting = true;
}
@@ -205,11 +337,15 @@ function main() {
map(keys(node), (v) => uci.set(uciconfig, nameHash, v, node[v]));
added++;
changed = true;
log(sprintf('Adding node: %s.', node.label));
});
uci.commit(uciconfig);
let need_restart = (via_proxy !== '1');
/* 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);
if (first_server) {
@@ -299,7 +435,12 @@ function main() {
log('Successfully updated subscriptions.');
}
if (!isEmpty(subscription_urls))
if (!isEmpty(subscription_urls)) {
if (!acquire_lock()) {
log('Another subscription update is running, aborting.');
exit(0);
}
try {
call(main);
} catch(e) {
@@ -311,3 +452,6 @@ if (!isEmpty(subscription_urls))
init_action('homeproxy', 'stop');
init_action('homeproxy', 'start');
}
release_lock();
}