mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-08 05:04:21 +08:00
⛅ Sync 2026-09-29 21:45:00
This commit is contained in:
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();
|
||||
}
|
||||
Reference in new issue
Block a user