mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-12 11:24:37 +08:00
901 lines
28 KiB
Ucode
Executable File
901 lines
28 KiB
Ucode
Executable File
#!/usr/bin/ucode
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Copyright (C) 2023 ImmortalWrt.org
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import { md5 } from 'digest';
|
|
import { open, writefile } from 'fs';
|
|
import { connect } from 'ubus';
|
|
import { cursor } from 'uci';
|
|
|
|
import { urldecode, urlencode } from 'luci.http';
|
|
import { remove_subscription_nodes } from '/etc/homeproxy/scripts/node_references.uc';
|
|
import { parse_surge_subscription } from '/etc/homeproxy/scripts/subscription_parsers.uc';
|
|
|
|
import {
|
|
wGETResult, decodeBase64Str, getTime, isEmpty, parseURL,
|
|
validation, HP_DIR, RUN_DIR
|
|
} from 'homeproxy';
|
|
|
|
/* UCI config start */
|
|
const uci = cursor();
|
|
|
|
const uciconfig = 'homeproxy';
|
|
uci.load(uciconfig);
|
|
|
|
const ucimain = 'config',
|
|
ucinode = 'node',
|
|
ucisubscription = 'subscription';
|
|
|
|
const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0',
|
|
allow_unsupported_tls_pin_fallback = uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback') || '0',
|
|
filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled',
|
|
filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [],
|
|
packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp',
|
|
subscription_urls = uci.get(uciconfig, ucisubscription, 'subscription_url') || [],
|
|
user_agent = uci.get(uciconfig, ucisubscription, 'user_agent'),
|
|
via_proxy = uci.get(uciconfig, ucisubscription, 'update_via_proxy') || '0';
|
|
|
|
const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainalnd_china';
|
|
let main_node, main_udp_node;
|
|
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;
|
|
|
|
let ret = false;
|
|
for (let i in filter_keywords) {
|
|
const patten = regexp(i);
|
|
if (match(name, patten))
|
|
ret = true;
|
|
}
|
|
if (filter_mode === 'whitelist')
|
|
ret = !ret;
|
|
|
|
return ret;
|
|
}
|
|
/* String helper end */
|
|
|
|
/* Common var start */
|
|
const node_cache = {},
|
|
node_result = [];
|
|
|
|
const ubus = connect();
|
|
const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {};
|
|
/* Common var end */
|
|
|
|
const SUBSCRIPTION_DIAGNOSTICS_PATH = RUN_DIR + '/subscription-diagnostics.json';
|
|
const SUBSCRIPTION_UPDATE_STATUS_PATH = RUN_DIR + '/subscription-update-status.json';
|
|
let subscription_diagnostics = [];
|
|
|
|
/* Log */
|
|
system(`mkdir -p ${RUN_DIR}`);
|
|
function log(...args) {
|
|
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
|
|
logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`);
|
|
logfile.close();
|
|
}
|
|
|
|
function reportSubscriptionDiagnostic(type, message, suggestion) {
|
|
push(subscription_diagnostics, {
|
|
type,
|
|
source: 'subscription',
|
|
message,
|
|
suggestion
|
|
});
|
|
}
|
|
|
|
function writeSubscriptionDiagnostics() {
|
|
system(`mkdir -p ${RUN_DIR}`);
|
|
|
|
if (length(subscription_diagnostics)) {
|
|
writefile(SUBSCRIPTION_DIAGNOSTICS_PATH, sprintf('%.J\n', {
|
|
time: time(),
|
|
items: subscription_diagnostics
|
|
}));
|
|
} else {
|
|
system(`rm -f ${SUBSCRIPTION_DIAGNOSTICS_PATH}`);
|
|
}
|
|
}
|
|
|
|
function writeSubscriptionUpdateStatus(running, completed, update_result, error) {
|
|
system(`mkdir -p ${RUN_DIR}`);
|
|
writefile(SUBSCRIPTION_UPDATE_STATUS_PATH, sprintf('%.J\n', {
|
|
time: time(),
|
|
running: !!running,
|
|
completed: !!completed,
|
|
update_result,
|
|
error: error || null
|
|
}));
|
|
}
|
|
|
|
function sanitizeCommandError(text) {
|
|
text = trim(text || '');
|
|
if (isEmpty(text))
|
|
return null;
|
|
|
|
text = split(text, '\n')[0] || text;
|
|
return text;
|
|
}
|
|
|
|
function uci_value_equal(a, b) {
|
|
if (type(a) == 'array')
|
|
a = map(a, (v) => sprintf('%s', v));
|
|
else
|
|
a = sprintf('%s', a);
|
|
|
|
if (type(b) == 'array')
|
|
b = map(b, (v) => sprintf('%s', v));
|
|
else
|
|
b = sprintf('%s', b);
|
|
|
|
return sprintf('%J', a) == sprintf('%J', b);
|
|
}
|
|
|
|
function setOrDeleteList(section, option, values) {
|
|
if (length(values))
|
|
uci.set(uciconfig, section, option, values);
|
|
else
|
|
uci.delete(uciconfig, section, option);
|
|
}
|
|
|
|
function logCleanupChange(change) {
|
|
if (!change || !change.node_id)
|
|
return;
|
|
|
|
let target = sprintf('%s.%s', change.section || '-', change.option || '-'),
|
|
action = change.action || 'update',
|
|
value = (action === 'set') ? sprintf(' to %s', change.value) : '';
|
|
|
|
log(sprintf('Cleaned reference to deleted subscription node %s: %s %s%s.',
|
|
change.node_id, target, action, value));
|
|
}
|
|
|
|
function version_at_least(version, major, minor, patch) {
|
|
const m = match(version || '', /^([0-9]+)\.([0-9]+)\.([0-9]+)/);
|
|
if (!m)
|
|
return false;
|
|
|
|
const current = [ int(m[1]), int(m[2]), int(m[3]) ],
|
|
required = [ major, minor, patch ];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
if (current[i] > required[i])
|
|
return true;
|
|
if (current[i] < required[i])
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function tls_cert_pin_unsupported() {
|
|
return !version_at_least(sing_features.version, 1, 13, 0);
|
|
}
|
|
|
|
function apply_tls_cert_pin_policy(config, label, pin) {
|
|
if (!pin || !tls_cert_pin_unsupported())
|
|
return;
|
|
|
|
const node_label = label || config.label || 'NULL',
|
|
version = sing_features.version || 'unknown';
|
|
|
|
if (config.tls_insecure === '1')
|
|
return;
|
|
|
|
if (allow_unsupported_tls_pin_fallback === '1' || config.type === 'hysteria2') {
|
|
config.tls_insecure = '1';
|
|
log(sprintf('Node %s uses server certificate fingerprint, but sing-box %s cannot express it; enabling explicit TLS insecure compatibility fallback.',
|
|
node_label,
|
|
version));
|
|
reportSubscriptionDiagnostic('warning',
|
|
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256;已降级为 TLS insecure 继续兼容。', node_label, version),
|
|
(config.type === 'hysteria2')
|
|
? '这是延续旧行为的兼容回退;如需更严格校验,请升级到支持 certificate_public_key_sha256 的 sing-box'
|
|
: '若安全要求较高,请关闭“Allow unsupported certificate pin fallback”,让此类节点默认跳过');
|
|
return;
|
|
}
|
|
|
|
config.__skip_reason = sprintf('该节点使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', version);
|
|
reportSubscriptionDiagnostic('warning',
|
|
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', node_label, version),
|
|
'升级到支持 certificate_public_key_sha256 的 sing-box,或显式开启兼容 fallback 后重新更新订阅');
|
|
log(sprintf('Skipping node %s: server certificate fingerprint is unsupported by sing-box %s.',
|
|
node_label,
|
|
version));
|
|
}
|
|
|
|
function service_action(action) {
|
|
return system([ '/etc/init.d/homeproxy', action ]);
|
|
}
|
|
|
|
function parse_uri(uri) {
|
|
let config, url, params;
|
|
|
|
if (type(uri) === 'object') {
|
|
if (uri.nodetype === 'sip008') {
|
|
/* https://shadowsocks.org/guide/sip008.html */
|
|
config = {
|
|
label: uri.remarks,
|
|
type: 'shadowsocks',
|
|
address: uri.server,
|
|
port: uri.server_port,
|
|
shadowsocks_encrypt_method: uri.method,
|
|
password: uri.password,
|
|
shadowsocks_plugin: uri.plugin,
|
|
shadowsocks_plugin_opts: uri.plugin_opts
|
|
};
|
|
}
|
|
} else if (type(uri) === 'string') {
|
|
uri = split(trim(uri), '://');
|
|
|
|
switch (uri[0]) {
|
|
case 'anytls':
|
|
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
params = url.searchParams || {};
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'anytls',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
password: urldecode(url.username),
|
|
tls: '1',
|
|
tls_sni: params.sni,
|
|
tls_insecure: (params.insecure === '1') ? '1' : '0'
|
|
};
|
|
|
|
break;
|
|
case 'http':
|
|
case 'https':
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'http',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
username: url.username ? urldecode(url.username) : null,
|
|
password: url.password ? urldecode(url.password) : null,
|
|
tls: (uri[0] === 'https') ? '1' : '0'
|
|
};
|
|
|
|
break;
|
|
case 'hysteria':
|
|
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
params = url.searchParams || {};
|
|
|
|
if (!sing_features.with_quic || (params.protocol && params.protocol !== 'udp')) {
|
|
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
|
if (!sing_features.with_quic)
|
|
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
|
|
|
return null;
|
|
}
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'hysteria',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
hysteria_protocol: params.protocol || 'udp',
|
|
hysteria_auth_type: params.auth ? 'string' : null,
|
|
hysteria_auth_payload: params.auth,
|
|
hysteria_obfs_password: params.obfsParam,
|
|
hysteria_down_mbps: params.downmbps,
|
|
hysteria_up_mbps: params.upmbps,
|
|
tls: '1',
|
|
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
|
|
tls_sni: params.peer,
|
|
tls_alpn: params.alpn
|
|
};
|
|
|
|
break;
|
|
case 'hysteria2':
|
|
case 'hy2':
|
|
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
params = url.searchParams || {};
|
|
|
|
if (!sing_features.with_quic) {
|
|
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
|
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
|
return null;
|
|
}
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'hysteria2',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
password: url.username ? (
|
|
urldecode(url.username + (url.password ? (':' + url.password) : ''))
|
|
) : null,
|
|
hysteria_obfs_type: params.obfs,
|
|
hysteria_obfs_password: params['obfs-password'],
|
|
tls: '1',
|
|
tls_insecure: (params.insecure === '1') ? '1' : '0',
|
|
tls_sni: params.sni
|
|
};
|
|
apply_tls_cert_pin_policy(config, config.label, params.pinSHA256);
|
|
|
|
break;
|
|
case 'socks':
|
|
case 'socks4':
|
|
case 'socks4a':
|
|
case 'socsk5':
|
|
case 'socks5h':
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'socks',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
username: url.username ? urldecode(url.username) : null,
|
|
password: url.password ? urldecode(url.password) : null,
|
|
socks_version: (match(uri[0], /4/)) ? '4' : '5'
|
|
};
|
|
|
|
break;
|
|
case 'ss':
|
|
/* "Lovely" Shadowrocket format */
|
|
const ss_suri = split(uri[1], '#');
|
|
let ss_slabel = '';
|
|
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;
|
|
}
|
|
|
|
/* 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 */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
|
|
let ss_userinfo = {};
|
|
if (url.username && url.password)
|
|
/* User info encoded with URIComponent */
|
|
ss_userinfo = [url.username, urldecode(url.password)];
|
|
else if (url.username)
|
|
/* User info encoded with base64 */
|
|
ss_userinfo = split(decodeBase64Str(urldecode(url.username)), ':', 2);
|
|
|
|
let ss_plugin, ss_plugin_opts;
|
|
if (url.search && url.searchParams.plugin) {
|
|
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];
|
|
}
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'shadowsocks',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
shadowsocks_encrypt_method: ss_userinfo[0],
|
|
password: ss_userinfo[1],
|
|
shadowsocks_plugin: ss_plugin,
|
|
shadowsocks_plugin_opts: ss_plugin_opts
|
|
};
|
|
|
|
break;
|
|
case 'trojan':
|
|
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
params = url.searchParams || {};
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'trojan',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
password: urldecode(url.username),
|
|
transport: (params.type !== 'tcp') ? params.type : null,
|
|
tls: '1',
|
|
tls_sni: params.sni
|
|
};
|
|
switch(params.type) {
|
|
case 'grpc':
|
|
config.grpc_servicename = params.serviceName;
|
|
break;
|
|
case 'ws':
|
|
config.ws_host = params.host ? urldecode(params.host) : null;
|
|
config.ws_path = params.path ? urldecode(params.path) : null;
|
|
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
|
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
|
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
|
config.ws_path = split(config.ws_path, '?ed=')[0];
|
|
}
|
|
break;
|
|
}
|
|
|
|
break;
|
|
case 'tuic':
|
|
/* https://github.com/daeuniverse/dae/discussions/182 */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
params = url.searchParams || {};
|
|
|
|
if (!sing_features.with_quic) {
|
|
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
|
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
|
|
|
return null;
|
|
}
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'tuic',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
uuid: url.username,
|
|
password: url.password ? urldecode(url.password) : null,
|
|
tuic_congestion_control: params.congestion_control,
|
|
tuic_udp_relay_mode: params.udp_relay_mode,
|
|
tls: '1',
|
|
tls_sni: params.sni,
|
|
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
|
|
};
|
|
|
|
break;
|
|
case 'vless':
|
|
/* https://github.com/XTLS/Xray-core/discussions/716 */
|
|
url = parseURL('http://' + uri[1]) || {};
|
|
params = url.searchParams || {};
|
|
|
|
/* Unsupported protocol */
|
|
if (params.type === 'kcp') {
|
|
log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
|
return null;
|
|
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !sing_features.with_quic)) {
|
|
log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
|
if (!sing_features.with_quic)
|
|
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
|
|
|
return null;
|
|
}
|
|
|
|
config = {
|
|
label: url.hash ? urldecode(url.hash) : null,
|
|
type: 'vless',
|
|
address: url.hostname,
|
|
port: url.port,
|
|
uuid: url.username,
|
|
transport: (params.type !== 'tcp') ? params.type : null,
|
|
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
|
|
tls_sni: params.sni,
|
|
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
|
|
tls_reality: (params.security === 'reality') ? '1' : '0',
|
|
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
|
|
tls_reality_short_id: params.sid,
|
|
tls_utls: sing_features.with_utls ? params.fp : null,
|
|
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
|
|
};
|
|
switch(params.type) {
|
|
case 'grpc':
|
|
config.grpc_servicename = params.serviceName;
|
|
break;
|
|
case 'http':
|
|
case 'tcp':
|
|
if (params.type === 'http' || params.headerType === 'http') {
|
|
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
|
|
config.http_path = params.path ? urldecode(params.path) : null;
|
|
}
|
|
break;
|
|
case 'httpupgrade':
|
|
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
|
|
config.http_path = params.path ? urldecode(params.path) : null;
|
|
break;
|
|
case 'ws':
|
|
config.ws_host = params.host ? urldecode(params.host) : null;
|
|
config.ws_path = params.path ? urldecode(params.path) : null;
|
|
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
|
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
|
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
|
config.ws_path = split(config.ws_path, '?ed=')[0];
|
|
}
|
|
break;
|
|
}
|
|
|
|
break;
|
|
case 'vmess':
|
|
/* "Lovely" shadowrocket format */
|
|
if (match(uri, /&/)) {
|
|
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
|
return null;
|
|
}
|
|
|
|
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
|
|
try {
|
|
uri = json(decodeBase64Str(uri[1])) || {};
|
|
} catch(e) {
|
|
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
|
return null;
|
|
}
|
|
|
|
if (uri.v != '2') {
|
|
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
|
return null;
|
|
/* Unsupported protocol */
|
|
} else if (uri.net === 'kcp') {
|
|
log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
|
|
return null;
|
|
} else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || uri.path || !sing_features.with_quic)) {
|
|
log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
|
|
if (!sing_features.with_quic)
|
|
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
|
|
|
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 (uri.aid && int(uri.aid) !== 0) {
|
|
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
|
|
* return null;
|
|
* }
|
|
*/
|
|
|
|
config = {
|
|
label: uri.ps ? urldecode(uri.ps) : null,
|
|
type: 'vmess',
|
|
address: uri.add,
|
|
port: uri.port,
|
|
uuid: uri.id,
|
|
vmess_alterid: uri.aid,
|
|
vmess_encrypt: uri.scy || 'auto',
|
|
vmess_global_padding: '1',
|
|
transport: (uri.net !== 'tcp') ? uri.net : null,
|
|
tls: (uri.tls === 'tls') ? '1' : '0',
|
|
tls_sni: uri.sni || uri.host,
|
|
tls_alpn: uri.alpn ? split(uri.alpn, ',') : null,
|
|
tls_utls: sing_features.with_utls ? uri.fp : null
|
|
};
|
|
switch (uri.net) {
|
|
case 'grpc':
|
|
config.grpc_servicename = uri.path;
|
|
break;
|
|
case 'h2':
|
|
case 'tcp':
|
|
if (uri.net === 'h2' || uri.type === 'http') {
|
|
config.transport = 'http';
|
|
config.http_host = uri.host ? split(uri.host, ',') : null;
|
|
config.http_path = uri.path;
|
|
}
|
|
break;
|
|
case 'httpupgrade':
|
|
config.httpupgrade_host = uri.host;
|
|
config.http_path = uri.path;
|
|
break;
|
|
case 'ws':
|
|
config.ws_host = uri.host;
|
|
config.ws_path = uri.path;
|
|
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
|
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
|
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
|
config.ws_path = split(config.ws_path, '?ed=')[0];
|
|
}
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isEmpty(config)) {
|
|
if (config.address)
|
|
config.address = replace(config.address, /\[|\]/g, '');
|
|
|
|
if (!validation('host', config.address) || !validation('port', config.port)) {
|
|
log(sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL'));
|
|
return null;
|
|
} else if (!config.label)
|
|
config.label = (validation('ip6addr', config.address) ?
|
|
`[${config.address}]` : config.address) + ':' + config.port;
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
function main() {
|
|
for (let url in subscription_urls) {
|
|
url = replace(url, /#.*$/, '');
|
|
const groupHash = md5(url);
|
|
node_cache[groupHash] = {};
|
|
|
|
const fetch_result = wGETResult(url, user_agent) || {},
|
|
res = fetch_result.stdout;
|
|
if (isEmpty(res)) {
|
|
const reason = sanitizeCommandError(fetch_result.stderr);
|
|
|
|
log(sprintf('Failed to fetch resources from %s.', url));
|
|
if (!isEmpty(reason))
|
|
log(sprintf('Fetch stderr: %s', reason));
|
|
reportSubscriptionDiagnostic('error',
|
|
sprintf('订阅地址拉取失败:%s', url),
|
|
reason || '请检查路由器到订阅源的连通性、DNS 解析和 TLS 访问是否正常');
|
|
continue;
|
|
}
|
|
|
|
let nodes, parsed_as_surge = false;
|
|
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) {
|
|
/* 先识别 Surge/Clash 托管配置格式(YAML 风格),再回退 Base64。 */
|
|
if (match(res, /(^|\n)[ \t]*proxies\s*:\s*(#[^\n]*)?(\n|$)/)) {
|
|
nodes = parse_surge_subscription({
|
|
isEmpty,
|
|
validation,
|
|
sing_features,
|
|
log,
|
|
apply_tls_cert_pin_policy
|
|
}, res);
|
|
parsed_as_surge = true;
|
|
} else {
|
|
nodes = decodeBase64Str(res);
|
|
nodes = nodes ? split(trim(replace(nodes, / /g, '_')), '\n') : [];
|
|
}
|
|
}
|
|
|
|
let count = 0;
|
|
for (let node in nodes) {
|
|
let config;
|
|
if (!isEmpty(node)) {
|
|
/* 信任边界来自控制流,而不是节点内容:只有 parse_surge_subscription()
|
|
* 分支产出的节点才会被当作已解析 config。任意 JSON 中伪造的
|
|
* 'nodetype'/'type' 不会绕过 parse_uri(),因为 JSON 路径下
|
|
* parsed_as_surge=false,仍然走原有校验和白名单映射。 */
|
|
if (parsed_as_surge && type(node) == 'object')
|
|
config = node;
|
|
else
|
|
config = parse_uri(node);
|
|
}
|
|
if (isEmpty(config))
|
|
continue;
|
|
if (!isEmpty(config.__skip_reason)) {
|
|
log(sprintf('Skipping node %s: %s', config.label || 'NULL', config.__skip_reason));
|
|
continue;
|
|
}
|
|
|
|
const label = config.label;
|
|
config.label = null;
|
|
const confHash = md5(sprintf('%J', config)),
|
|
nameHash = md5(label);
|
|
config.label = label;
|
|
|
|
if (filter_check(config.label))
|
|
log(sprintf('Skipping blacklist node: %s.', config.label));
|
|
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
|
|
log(sprintf('Skipping duplicate node: %s.', config.label));
|
|
else {
|
|
if (config.tls === '1' && allow_insecure === '1')
|
|
config.tls_insecure = '1';
|
|
if (config.type in ['vless', 'vmess'])
|
|
config.packet_encoding = packet_encoding;
|
|
|
|
config.grouphash = groupHash;
|
|
push(node_result, []);
|
|
push(node_result[length(node_result)-1], config);
|
|
node_cache[groupHash][confHash] = config;
|
|
node_cache[groupHash][nameHash] = config;
|
|
|
|
count++;
|
|
}
|
|
}
|
|
|
|
if (count == 0)
|
|
log(sprintf('No valid node found in %s.', url));
|
|
else
|
|
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), url));
|
|
}
|
|
|
|
if (isEmpty(node_result)) {
|
|
let fetch_errors = filter(subscription_diagnostics, (item) => item?.type === 'error' && item?.source === 'subscription');
|
|
|
|
log('Failed to update subscriptions: no valid node found.');
|
|
if (!length(fetch_errors))
|
|
reportSubscriptionDiagnostic('error',
|
|
'订阅更新失败:没有找到可用节点。',
|
|
'请检查订阅地址、过滤规则,以及是否有节点因当前 sing-box 不支持证书指纹而被跳过');
|
|
|
|
return false;
|
|
}
|
|
|
|
let added = 0, updated = 0, removed = 0,
|
|
stale_nodes = [],
|
|
stale_labels = {};
|
|
uci.foreach(uciconfig, ucinode, (cfg) => {
|
|
/* Nodes created by the user */
|
|
if (!cfg.grouphash)
|
|
return null;
|
|
|
|
/* Empty object - failed to fetch nodes */
|
|
if (length(node_cache[cfg.grouphash]) === 0)
|
|
return null;
|
|
|
|
if (!node_cache[cfg.grouphash] || !node_cache[cfg.grouphash][cfg['.name']]) {
|
|
push(stale_nodes, cfg['.name']);
|
|
stale_labels[cfg['.name']] = cfg.label || cfg['.name'];
|
|
return null;
|
|
} else {
|
|
const node = node_cache[cfg.grouphash][cfg['.name']];
|
|
let node_changed = false;
|
|
|
|
for (let v in node) {
|
|
if (isEmpty(node[v]))
|
|
continue;
|
|
|
|
if (!(v in cfg) || !uci_value_equal(cfg[v], node[v]))
|
|
node_changed = true;
|
|
|
|
uci.set(uciconfig, cfg['.name'], v, node[v]);
|
|
}
|
|
map(keys(cfg), (v) => {
|
|
if (substr(v, 0, 1) == '.')
|
|
return null;
|
|
|
|
if (!(v in node) || isEmpty(node[v])) {
|
|
uci.delete(uciconfig, cfg['.name'], v);
|
|
node_changed = true;
|
|
}
|
|
});
|
|
|
|
if (node_changed)
|
|
updated++;
|
|
|
|
node.isExisting = true;
|
|
}
|
|
});
|
|
|
|
if (length(stale_nodes)) {
|
|
const cleanup = remove_subscription_nodes(uci, uciconfig, stale_nodes);
|
|
removed += cleanup.removed;
|
|
|
|
for (let node_id in stale_nodes)
|
|
log(sprintf('Removing node: %s.', stale_labels[node_id] || node_id));
|
|
|
|
if (cleanup.changed) {
|
|
if (uci.get(uciconfig, ucimain, 'main_node') !== main_node)
|
|
log(sprintf('Deleted subscription node affected main node, falling back to %s.',
|
|
uci.get(uciconfig, ucimain, 'main_node') || 'nil'));
|
|
if (uci.get(uciconfig, ucimain, 'main_udp_node') !== main_udp_node)
|
|
log(sprintf('Deleted subscription node affected main UDP node, falling back to %s.',
|
|
uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'));
|
|
|
|
for (let change in cleanup.changes)
|
|
logCleanupChange(change);
|
|
}
|
|
}
|
|
|
|
for (let nodes in node_result)
|
|
map(nodes, (node) => {
|
|
if (node.isExisting)
|
|
return null;
|
|
|
|
const nameHash = md5(node.label);
|
|
uci.set(uciconfig, nameHash, 'node');
|
|
map(keys(node), (v) => uci.set(uciconfig, nameHash, v, node[v]));
|
|
|
|
added++;
|
|
log(sprintf('Adding node: %s.', node.label));
|
|
});
|
|
uci.commit(uciconfig);
|
|
|
|
let need_restart = (via_proxy !== '1') || added > 0 || updated > 0 || removed > 0;
|
|
if (!isEmpty(main_node)) {
|
|
const has_server = !!uci.get_first(uciconfig, ucinode);
|
|
if (has_server) {
|
|
let main_urltest_nodes;
|
|
if (main_node === 'urltest') {
|
|
main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (v) => {
|
|
if (!uci.get(uciconfig, v)) {
|
|
log(sprintf('Node %s is gone, removing from urltest list.', v));
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
setOrDeleteList(ucimain, 'main_urltest_nodes', main_urltest_nodes);
|
|
uci.commit(uciconfig);
|
|
}
|
|
|
|
if ((main_node === 'urltest') ? !length(main_urltest_nodes) : !uci.get(uciconfig, main_node)) {
|
|
uci.set(uciconfig, ucimain, 'main_node', 'nil');
|
|
uci.commit(uciconfig);
|
|
need_restart = true;
|
|
|
|
log('Main node is gone, disabling main node.');
|
|
}
|
|
|
|
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
|
|
let main_udp_urltest_nodes;
|
|
if (main_udp_node === 'urltest') {
|
|
main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [], (v) => {
|
|
if (!uci.get(uciconfig, v)) {
|
|
log(sprintf('Node %s is gone, removing from urltest list.', v));
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
setOrDeleteList(ucimain, 'main_udp_urltest_nodes', main_udp_urltest_nodes);
|
|
uci.commit(uciconfig);
|
|
}
|
|
|
|
if ((main_udp_node === 'urltest') ? !length(main_udp_urltest_nodes) : !uci.get(uciconfig, main_udp_node)) {
|
|
uci.set(uciconfig, ucimain, 'main_udp_node', 'same');
|
|
uci.commit(uciconfig);
|
|
need_restart = true;
|
|
|
|
log('Main UDP node is gone, falling back to the main node.');
|
|
}
|
|
}
|
|
} else {
|
|
uci.set(uciconfig, ucimain, 'main_node', 'nil');
|
|
uci.set(uciconfig, ucimain, 'main_udp_node', 'nil');
|
|
uci.commit(uciconfig);
|
|
need_restart = true;
|
|
|
|
log('No available node, disable tproxy.');
|
|
}
|
|
}
|
|
|
|
if (need_restart) {
|
|
log('Restarting service...');
|
|
service_action('stop');
|
|
service_action('start');
|
|
}
|
|
|
|
log(sprintf('%s nodes added, %s updated, %s removed.', added, updated, removed));
|
|
log('Successfully updated subscriptions.');
|
|
|
|
return true;
|
|
}
|
|
|
|
if (!isEmpty(subscription_urls)) {
|
|
writeSubscriptionUpdateStatus(true, false, null, null);
|
|
|
|
try {
|
|
const ok = call(main);
|
|
writeSubscriptionUpdateStatus(false, true, ok === false ? false : true,
|
|
(ok === false) ? (subscription_diagnostics[0]?.message || '订阅更新失败。') : null);
|
|
} catch(e) {
|
|
const status_error = sprintf('订阅更新异常:%s: %s', e.type || 'error', e.message || e);
|
|
|
|
log('[FATAL ERROR] An error occurred during updating subscriptions:');
|
|
log(sprintf('%s: %s', e.type, e.message));
|
|
log(e.stacktrace[0].context);
|
|
reportSubscriptionDiagnostic('error',
|
|
status_error,
|
|
'请查看 HomeProxy 日志中的 [SUBSCRIBE] 记录并修复订阅配置');
|
|
|
|
log('Restarting service...');
|
|
service_action('stop');
|
|
service_action('start');
|
|
writeSubscriptionUpdateStatus(false, true, false, status_error);
|
|
}
|
|
} else {
|
|
subscription_diagnostics = [];
|
|
writeSubscriptionUpdateStatus(false, true, false, '未配置订阅地址。');
|
|
}
|
|
|
|
writeSubscriptionDiagnostics();
|