mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 20:53:56 +08:00
🎨 Sync 2026-09-15 11:19:33
This commit is contained in:
1 parent
4a5cae8f8f
commit
cca204b9e2
106 files changed
+17125
-19121
No files matched your search
@@ -25,42 +25,49 @@ config homeproxy 'migration'
|
||||
config homeproxy 'config'
|
||||
option main_node 'nil'
|
||||
option main_udp_node 'same'
|
||||
option dns_server '8.8.8.8'
|
||||
option china_dns_server '223.5.5.5'
|
||||
option dns_server 'https://dns.quad9.net/dns-query'
|
||||
option china_dns_server 'https://dns.alidns.com/dns-query'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option routing_port 'common'
|
||||
option proxy_mode 'redirect_tproxy'
|
||||
option ipv6_support '1'
|
||||
option github_token ''
|
||||
option log_level 'warn'
|
||||
option clash_api_enabled '1'
|
||||
option clash_api_external_controller '127.0.0.1:9090'
|
||||
option clash_api_default_mode 'Rule'
|
||||
list clash_api_allow_origin 'https://metacubex.github.io'
|
||||
list clash_api_allow_origin 'https://metacubexd.pages.dev'
|
||||
list clash_api_allow_origin 'http://d.metacubex.one'
|
||||
list clash_api_allow_origin 'https://yacd.metacubex.one'
|
||||
option clash_api_allow_private_network '1'
|
||||
option metacubexd_url 'https://metacubexd.pages.dev/#/overview'
|
||||
|
||||
config homeproxy 'control'
|
||||
option lan_proxy_mode 'disabled'
|
||||
list wan_proxy_ipv4_ips '91.105.192.0/23'
|
||||
list wan_proxy_ipv4_ips '91.108.4.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.8.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.16.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.12.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.20.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.8.0/21'
|
||||
list wan_proxy_ipv4_ips '91.108.16.0/21'
|
||||
list wan_proxy_ipv4_ips '91.108.56.0/22'
|
||||
list wan_proxy_ipv4_ips '95.161.64.0/20'
|
||||
list wan_proxy_ipv4_ips '149.154.160.0/20'
|
||||
list wan_proxy_ipv4_ips '185.76.151.0/24'
|
||||
list wan_proxy_ipv4_ips '203.208.50.66/32'
|
||||
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23c::/47'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
|
||||
list wan_proxy_ipv6_ips '2a0a:f280::/32'
|
||||
|
||||
config homeproxy 'clash_api'
|
||||
option external_ui '/etc/homeproxy/run/ui'
|
||||
option external_ui_download_url 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip'
|
||||
option external_ui_download_detour 'direct-out'
|
||||
option external_controller '0.0.0.0:9095'
|
||||
option secret 'homeproxy'
|
||||
option default_mode 'rule'
|
||||
|
||||
config homeproxy 'ntp'
|
||||
option enabled '1'
|
||||
option server 'ntp.aliyun.com'
|
||||
option server_port '123'
|
||||
option interval '30m'
|
||||
|
||||
config homeproxy 'cache'
|
||||
option enabled '1'
|
||||
option path '/etc/homeproxy/cache.db'
|
||||
option store_rdrc '1'
|
||||
|
||||
config homeproxy 'routing'
|
||||
option sniff_override '1'
|
||||
option default_outbound 'direct-out'
|
||||
@@ -75,12 +82,11 @@ config homeproxy 'dns'
|
||||
config homeproxy 'subscription'
|
||||
option auto_update '0'
|
||||
option allow_insecure '0'
|
||||
option allow_unsupported_tls_pin_fallback '0'
|
||||
option latency_test_mode 'icmp'
|
||||
option packet_encoding 'xudp'
|
||||
option update_via_proxy '0'
|
||||
option filter_nodes 'blacklist'
|
||||
list filter_keywords '重置|到期|过期|剩余|套餐'
|
||||
list filter_keywords 'Expiration|Remaining'
|
||||
option auto_update_time '0 0 * * *'
|
||||
option filter_nodes 'disabled'
|
||||
|
||||
config homeproxy 'server'
|
||||
option enabled '0'
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
20260811040949
|
||||
202609130005
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
20260811040949
|
||||
202609130005
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
202608102230
|
||||
202609130005
|
||||
@@ -4,6 +4,8 @@
|
||||
1000giri.net
|
||||
10beasts.net
|
||||
10musume.com
|
||||
123av.com
|
||||
123av.me
|
||||
12bet.com
|
||||
12vpn.com
|
||||
12vpn.net
|
||||
@@ -213,18 +215,15 @@ aoxvpn.com
|
||||
apartmentratings.com
|
||||
apartments.com
|
||||
apat1989.org
|
||||
api-gl.lbkrs.com
|
||||
api-secure.recaptcha.net
|
||||
api-verify.recaptcha.net
|
||||
api.ai
|
||||
api.futunn.com
|
||||
api.lbkrs.com
|
||||
api.moomoobull.com
|
||||
api.palworldgame.com
|
||||
api.pureapk.com
|
||||
api.recaptcha.net
|
||||
api.shuaiapi.com
|
||||
api.steampowered.com
|
||||
api5.futunn.com
|
||||
apiary.io
|
||||
apigee.com
|
||||
apk.support
|
||||
@@ -562,6 +561,7 @@ break.com
|
||||
breakgfw.com
|
||||
breakingtweets.com
|
||||
breakwall.net
|
||||
briarproject.org
|
||||
brill.com
|
||||
brizzly.com
|
||||
broadpressinc.com
|
||||
@@ -667,7 +667,6 @@ cdjp.org
|
||||
cdn-images.mailchimp.com
|
||||
cdn-telegram.org
|
||||
cdn.arstechnica.net
|
||||
cdn.futustatic.com
|
||||
cdn.jwplayer.com
|
||||
cdn.printfriendly.com
|
||||
cdn.seatguru.com
|
||||
@@ -704,6 +703,7 @@ character.ai
|
||||
chat.com
|
||||
chat.lmsys.org
|
||||
chatgpt.com
|
||||
chatgpt.site
|
||||
chatpdf.com
|
||||
chaturbate.com
|
||||
checkgfw.com
|
||||
@@ -816,7 +816,6 @@ cn.fmnnow.com
|
||||
cn.sandscotaicentral.com
|
||||
cn.shafaqna.com
|
||||
cn.theaustralian.com.au
|
||||
cn.uptodown.com
|
||||
cna.com.tw
|
||||
cnbbnews.wordpress.com
|
||||
cnbeta.com.tw
|
||||
@@ -897,6 +896,7 @@ css.pixnet.in
|
||||
csuchen.de
|
||||
csw.org.uk
|
||||
ct.org.tw
|
||||
ctee.com.tw
|
||||
ctinets.com
|
||||
ctinews.com
|
||||
ctinsider.com
|
||||
@@ -1044,7 +1044,6 @@ dougscripts.com
|
||||
doujincafe.com
|
||||
dowjones.io
|
||||
download.dappcdn.com
|
||||
download.futunn.com
|
||||
dpool.top
|
||||
dpp.org.tw
|
||||
dpr.info
|
||||
@@ -1449,6 +1448,7 @@ geti2p.net
|
||||
getlantern.org
|
||||
getmalus.com
|
||||
getmdl.io
|
||||
getmonero.org
|
||||
getoutline.org
|
||||
getsession.org
|
||||
getsync.com
|
||||
@@ -1838,6 +1838,7 @@ help.byspotify.com
|
||||
helpeachpeople.com
|
||||
helpster.de
|
||||
hembed.com
|
||||
hentai.tv
|
||||
heqinglian.net
|
||||
heritage.org
|
||||
herokuapp.com
|
||||
@@ -1863,6 +1864,7 @@ himalayan-foundation.org
|
||||
himemix.com
|
||||
hindustantimes.com
|
||||
hinet.net
|
||||
historylines.net
|
||||
hitbtc.com
|
||||
hitomi.la
|
||||
hive.blog
|
||||
@@ -1879,6 +1881,7 @@ hkcnews.com
|
||||
hkcoc.com
|
||||
hkcoc.weather.com.hk
|
||||
hkdc.us
|
||||
hkej.com
|
||||
hket.com
|
||||
hkfaa.com
|
||||
hkgalden.com
|
||||
@@ -2205,6 +2208,7 @@ kinmen.org.tw
|
||||
kinmen.travel
|
||||
kinokuniya.com
|
||||
kiwi.kz
|
||||
kiwix.org
|
||||
kk-whys.co.jp
|
||||
kkbox.com
|
||||
kknews.cc
|
||||
@@ -2300,6 +2304,7 @@ linkedin.com
|
||||
linktr.ee
|
||||
linux.do
|
||||
linux.org.hk
|
||||
linux.sb
|
||||
liquiditytp.com
|
||||
liquidvpn.com
|
||||
listennotes.com
|
||||
@@ -2312,6 +2317,7 @@ liveleak.com
|
||||
livemint.com
|
||||
livestream.com
|
||||
livevideo.com
|
||||
livingatlas.arcgis.com
|
||||
livingstream.com
|
||||
lmarena.ai
|
||||
localbitcoins.com
|
||||
@@ -2353,7 +2359,6 @@ lvv2.com
|
||||
lyfhk.net
|
||||
lzjscript.com
|
||||
lzmtnews.org
|
||||
m.hkgalden.com
|
||||
m.me
|
||||
m.moegirl.org
|
||||
macgamestore.com
|
||||
@@ -2399,6 +2404,7 @@ mattwilcox.net
|
||||
maven.neoforged.net
|
||||
mcadforums.com
|
||||
mcaf.ee
|
||||
mceindex.com
|
||||
mcusercontent.com
|
||||
md-t.org
|
||||
me.me
|
||||
@@ -2643,6 +2649,7 @@ nga.mil
|
||||
nhentai.net
|
||||
nic.cz.cc
|
||||
nic.gov
|
||||
nicoseiga.jp
|
||||
nicovideo.jp
|
||||
nightswatch.top
|
||||
nikke-en.com
|
||||
@@ -2728,6 +2735,7 @@ oklink.com
|
||||
okpool.me
|
||||
okx.com
|
||||
old.honeynet.org
|
||||
oldmapsonline.org
|
||||
olehdtv.com
|
||||
olelive.com
|
||||
olevod.com
|
||||
@@ -2759,12 +2767,8 @@ ooni.org
|
||||
open.firstory.me
|
||||
openai.com
|
||||
openapi-quote.longbridge.cn
|
||||
openapi-quote.longbridge.com
|
||||
openapi-trade.longbridge.cn
|
||||
openapi-trade.longbridge.com
|
||||
openapi.futunn.com
|
||||
openapi.longbridge.cn
|
||||
openapi.longbridge.com
|
||||
openart.ai
|
||||
opencritic.com
|
||||
opendemocracy.net
|
||||
@@ -3020,9 +3024,8 @@ qmp4.com
|
||||
qoos.com
|
||||
qq.co.za
|
||||
qstatus.com
|
||||
qtcard.futunn.com
|
||||
qtcardfthk.futufin.com
|
||||
qtrac.eu
|
||||
quakemachinex.com
|
||||
questvisual.com
|
||||
quitccp.org
|
||||
quiz.directory
|
||||
@@ -3113,6 +3116,7 @@ rlwlw.com
|
||||
rmbl.ws
|
||||
roblox.com
|
||||
robustnessiskey.com
|
||||
roc-taiwan.org
|
||||
rocket-inc.net
|
||||
rocket.chat
|
||||
rocksdb.org
|
||||
@@ -3122,6 +3126,7 @@ rolia.net
|
||||
rolsociety.org
|
||||
ronjoneswriter.com
|
||||
rou.video
|
||||
roushuwo.com
|
||||
rsdlmonitor.com
|
||||
rsf-chinese.org
|
||||
rsf.org
|
||||
@@ -3147,6 +3152,7 @@ rxhj.net
|
||||
s-cute.com
|
||||
s.yimg.com
|
||||
s3-ap-northeast-1.amazonaws.com
|
||||
s3.dualstack.us-west-2.amazonaws.com
|
||||
sacks.com
|
||||
sacom.hk
|
||||
sadistic-v.com
|
||||
@@ -3166,6 +3172,7 @@ savetibet.de
|
||||
savetibet.org
|
||||
savetibetstore.org
|
||||
saveuighur.org
|
||||
sb.sb
|
||||
sbme.me
|
||||
sbti.unun.dev
|
||||
schema.org
|
||||
@@ -3422,7 +3429,7 @@ supchina.com
|
||||
superpages.com
|
||||
supervpn.net
|
||||
superzooi.com
|
||||
support.futunn.com
|
||||
supjav.com
|
||||
supremecourt.gov
|
||||
suprememastertv.com
|
||||
surfeasy.com
|
||||
@@ -3452,10 +3459,12 @@ taa-usa.org
|
||||
taaze.tw
|
||||
taedp.org.tw
|
||||
tagwalk.com
|
||||
taiav.com
|
||||
tails.net
|
||||
taipeisociety.org
|
||||
taipeitimes.com
|
||||
taisounds.com
|
||||
taiwanembassy.org
|
||||
taiwanhot.net
|
||||
taiwanjustice.net
|
||||
taiwanncf.org.tw
|
||||
@@ -3684,8 +3693,6 @@ totalvpn.com
|
||||
tou.tv
|
||||
tpi.org.tw
|
||||
tracfone.com
|
||||
trade.futunn.com
|
||||
trade.longportapp.com
|
||||
tradeup.com
|
||||
tradingview.com
|
||||
translate.goog
|
||||
@@ -4092,6 +4099,7 @@ wiktionary.org
|
||||
willw.net
|
||||
wilsoncenter.org
|
||||
windscribe.com
|
||||
windy.com
|
||||
wingamestore.com
|
||||
wingy.site
|
||||
wionews.com
|
||||
@@ -4134,9 +4142,11 @@ woyaolian.org
|
||||
wp.com
|
||||
wplace.live
|
||||
wpoforum.com
|
||||
write.as
|
||||
writesonic.com
|
||||
wsj.com
|
||||
wsj.net
|
||||
wtfpass.com
|
||||
wufafangwen.com
|
||||
wufi.org.tw
|
||||
wujieliulan.com
|
||||
@@ -4182,6 +4192,7 @@ x.co
|
||||
x.com
|
||||
x.company
|
||||
x3guide.com
|
||||
x86.fr
|
||||
xbabe.com
|
||||
xbookcn.com
|
||||
xbtce.com
|
||||
|
||||
@@ -1 +1 @@
|
||||
202608102230
|
||||
202609130005
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy Clash API controller 地址 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
|
||||
function is_empty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
export function first_value(value) {
|
||||
return (type(value) === 'array') ? value[0] : value;
|
||||
};
|
||||
|
||||
export function strip_cidr(value) {
|
||||
return replace(trim(first_value(value) || ''), /\/.*$/, '');
|
||||
};
|
||||
|
||||
export function is_ipv4_address(value) {
|
||||
return !!match(value || '', /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/);
|
||||
};
|
||||
|
||||
export function is_portable_bind_host(value) {
|
||||
value = lc(trim(value || ''));
|
||||
|
||||
return value === 'localhost' ||
|
||||
value === '0.0.0.0' ||
|
||||
value === '::' ||
|
||||
value === '[::]' ||
|
||||
value === '::1' ||
|
||||
value === '[::1]' ||
|
||||
!!match(value, /^127\./);
|
||||
};
|
||||
|
||||
export function append_ipv4_address(addresses, value) {
|
||||
value = strip_cidr(value);
|
||||
|
||||
if (is_ipv4_address(value) && index(addresses, value) === -1)
|
||||
push(addresses, value);
|
||||
};
|
||||
|
||||
export function local_ipv4_addresses(command_output) {
|
||||
const netuci = cursor();
|
||||
let addresses = [];
|
||||
|
||||
netuci.load('network');
|
||||
|
||||
let lan_ipaddr = netuci.get('network', 'lan', 'ipaddr');
|
||||
if (type(lan_ipaddr) === 'array') {
|
||||
for (let addr in lan_ipaddr)
|
||||
append_ipv4_address(addresses, addr);
|
||||
} else {
|
||||
append_ipv4_address(addresses, lan_ipaddr);
|
||||
}
|
||||
|
||||
const ip_addresses = (type(command_output) === 'function')
|
||||
? (command_output('/sbin/ip -4 -o addr show scope global') || '')
|
||||
: '';
|
||||
for (let line in split(ip_addresses, /\n/)) {
|
||||
let matched = match(line, /[ \t]inet[ \t]+([0-9.]+)(\/[0-9]+)?[ \t]/);
|
||||
if (matched)
|
||||
append_ipv4_address(addresses, matched[1]);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
};
|
||||
|
||||
export function parse_controller(value, default_port) {
|
||||
value = trim(replace(first_value(value) || '', /^https?:\/\//, ''));
|
||||
value = replace(value, /\/.*$/, '');
|
||||
|
||||
let matched = match(value, /^\[([^\]]+)\](:([0-9]+))?$/);
|
||||
if (matched)
|
||||
return { host: matched[1], port: int(matched[3] || default_port) };
|
||||
|
||||
matched = match(value, /^([^:]+):([0-9]+)$/);
|
||||
if (matched)
|
||||
return { host: matched[1], port: int(matched[2]) };
|
||||
|
||||
return { host: value, port: int(default_port) };
|
||||
};
|
||||
|
||||
export function format_controller(host, port) {
|
||||
return sprintf((index(host, ':') >= 0) ? '[%s]:%d' : '%s:%d', host, port);
|
||||
};
|
||||
|
||||
export function normalize_local_controller_option(uci, config, section, local_addresses, fallback_host, option, default_port, set_when_empty) {
|
||||
let value = uci.get(config, section, option);
|
||||
|
||||
if (is_empty(value)) {
|
||||
if (!set_when_empty)
|
||||
return null;
|
||||
|
||||
let next = format_controller(fallback_host, default_port);
|
||||
uci.set(config, section, option, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
let controller = parse_controller(value, default_port),
|
||||
host = controller.host;
|
||||
|
||||
if (is_empty(host))
|
||||
host = fallback_host;
|
||||
else if (is_portable_bind_host(host))
|
||||
return null;
|
||||
else if (!is_ipv4_address(host) || index(local_addresses, host) !== -1)
|
||||
return null;
|
||||
|
||||
let next = format_controller(fallback_host, controller.port || default_port);
|
||||
uci.set(config, section, option, next);
|
||||
return next;
|
||||
};
|
||||
@@ -1,963 +0,0 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Clash API display proxy for HomeProxy dashboards.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import * as socket from 'socket';
|
||||
import * as uloop from 'uloop';
|
||||
import { cursor } from 'uci';
|
||||
import { urldecode, urlencode } from 'luci.http';
|
||||
import { parse_controller } from '/etc/homeproxy/scripts/clash_api.uc';
|
||||
|
||||
const uci = cursor();
|
||||
const uciconfig = 'homeproxy';
|
||||
const ucimain = 'config';
|
||||
const shadowtls_suffix = '-out-shadowtls';
|
||||
const filter_timeout = 10000;
|
||||
const fallback_delay_limit = 5;
|
||||
const upstream_read_timeout = 5; // 秒;单次上游读取超时
|
||||
|
||||
// Resource limits
|
||||
const MAX_CONNECTIONS = 64;
|
||||
const MAX_REQUEST_BUFFER_SIZE = 256 * 1024; // 256KB
|
||||
const MAX_RESPONSE_BUFFER_SIZE = 8 * 1024 * 1024; // 8MB
|
||||
const CLIENT_IDLE_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
let next_id = 1;
|
||||
let connections = {};
|
||||
let target, listen, server;
|
||||
|
||||
uci.load(uciconfig);
|
||||
|
||||
function isEmpty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (type(value) === 'array')
|
||||
return value;
|
||||
else if (isEmpty(value))
|
||||
return [];
|
||||
|
||||
return [ value ];
|
||||
}
|
||||
|
||||
function deriveProxyController(target) {
|
||||
target = parse_controller(target || '127.0.0.1:9090', 9090);
|
||||
|
||||
if (isEmpty(target.host))
|
||||
target.host = '127.0.0.1';
|
||||
|
||||
return { host: target.host, port: 9091 };
|
||||
}
|
||||
|
||||
function sendAll(sock, data) {
|
||||
let offset = 0;
|
||||
|
||||
while (offset < length(data)) {
|
||||
let sent = sock.send(substr(data, offset));
|
||||
|
||||
if (sent === null || sent <= 0)
|
||||
return false;
|
||||
|
||||
offset += sent;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function recvAvailable(sock) {
|
||||
let data = '';
|
||||
let eof = false;
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
let chunk = sock.recv(16384);
|
||||
|
||||
if (chunk === null)
|
||||
break;
|
||||
|
||||
if (length(chunk) === 0) {
|
||||
eof = true;
|
||||
break;
|
||||
}
|
||||
|
||||
data += chunk;
|
||||
|
||||
if (length(chunk) < 16384)
|
||||
break;
|
||||
}
|
||||
|
||||
return { data, eof };
|
||||
}
|
||||
|
||||
function contentLength(headers) {
|
||||
for (let line in headers) {
|
||||
let matched = match(lc(line), /^content-length:[ \t]*([0-9]+)/);
|
||||
|
||||
if (matched)
|
||||
return int(matched[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function headerValue(headers, name) {
|
||||
name = lc(name);
|
||||
|
||||
for (let line in headers) {
|
||||
let matched = match(line, /^([^:]+):(.*)$/);
|
||||
|
||||
if (matched && lc(trim(matched[1])) === name)
|
||||
return trim(matched[2]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathSegment(value) {
|
||||
return replace(urlencode(value), /\+/g, '%20');
|
||||
}
|
||||
|
||||
function parseHttpMessage(raw, body_to_eof) {
|
||||
let header_end = index(raw, "\r\n\r\n");
|
||||
|
||||
if (header_end < 0)
|
||||
return null;
|
||||
|
||||
let header_text = substr(raw, 0, header_end);
|
||||
let headers = split(header_text, "\r\n");
|
||||
let body_start = header_end + 4;
|
||||
let body_len = contentLength(headers);
|
||||
|
||||
if (body_len === null)
|
||||
body_len = body_to_eof ? length(raw) - body_start : 0;
|
||||
|
||||
let complete_len = body_start + body_len;
|
||||
|
||||
if (length(raw) < complete_len)
|
||||
return null;
|
||||
|
||||
let request_line = split(headers[0] || '', /[ \t]+/);
|
||||
|
||||
return {
|
||||
raw: substr(raw, 0, complete_len),
|
||||
headers,
|
||||
body: substr(raw, body_start, body_len),
|
||||
complete_len,
|
||||
method: request_line[0] || '',
|
||||
path: request_line[1] || '/'
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFilter(method, path) {
|
||||
return method === 'GET' && (
|
||||
path === '/proxies' ||
|
||||
index(path, '/proxies?') === 0 ||
|
||||
index(path, '/proxies/') === 0 ||
|
||||
path === '/providers/proxies' ||
|
||||
index(path, '/providers/proxies?') === 0 ||
|
||||
index(path, '/providers/proxies/') === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isUpgradeRequest(request) {
|
||||
return !!headerValue(request.headers, 'Upgrade') ||
|
||||
index(lc(headerValue(request.headers, 'Connection') || ''), 'upgrade') >= 0;
|
||||
}
|
||||
|
||||
function rebuildCloseRequest(request) {
|
||||
let headers = [];
|
||||
|
||||
for (let i = 0; i < length(request.headers); i++) {
|
||||
let line = request.headers[i],
|
||||
header = lc(line);
|
||||
|
||||
if (i > 0 && match(header, /^(accept-encoding|connection):/))
|
||||
continue;
|
||||
|
||||
push(headers, line);
|
||||
}
|
||||
|
||||
push(headers, 'Accept-Encoding: identity');
|
||||
push(headers, 'Connection: close');
|
||||
|
||||
return join("\r\n", headers) + "\r\n\r\n" + request.body;
|
||||
}
|
||||
|
||||
function isShadowTlsTag(name) {
|
||||
return type(name) === 'string' &&
|
||||
length(name) > length(shadowtls_suffix) &&
|
||||
substr(name, length(name) - length(shadowtls_suffix)) === shadowtls_suffix;
|
||||
}
|
||||
|
||||
function isHiddenProxyTag(name) {
|
||||
return isShadowTlsTag(name);
|
||||
}
|
||||
|
||||
function filterProxyItem(item) {
|
||||
if (type(item) !== 'object')
|
||||
return;
|
||||
|
||||
if (type(item.all) === 'array')
|
||||
item.all = filter(item.all, (name) => !isHiddenProxyTag(name));
|
||||
}
|
||||
|
||||
function filterProxyArray(items) {
|
||||
return filter(items, (item) => {
|
||||
if (type(item) === 'string')
|
||||
return !isHiddenProxyTag(item);
|
||||
|
||||
if (type(item) === 'object' && isHiddenProxyTag(item.name))
|
||||
return false;
|
||||
|
||||
filterProxyItem(item);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function filterProxiesPayload(payload) {
|
||||
if (type(payload) !== 'object')
|
||||
return payload;
|
||||
|
||||
filterProxyItem(payload);
|
||||
|
||||
if (type(payload.proxies) === 'object') {
|
||||
for (let name in payload.proxies) {
|
||||
if (isHiddenProxyTag(name)) {
|
||||
delete payload.proxies[name];
|
||||
continue;
|
||||
}
|
||||
|
||||
filterProxyItem(payload.proxies[name]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type(payload.providers) === 'object') {
|
||||
for (let name in payload.providers) {
|
||||
let provider = payload.providers[name];
|
||||
|
||||
if (type(provider) !== 'object')
|
||||
continue;
|
||||
|
||||
if (type(provider.proxies) === 'array')
|
||||
provider.proxies = filterProxyArray(provider.proxies);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function parseHex(value) {
|
||||
let result = 0;
|
||||
value = lc(trim(split(value, ';', 2)[0] || ''));
|
||||
|
||||
for (let i = 0; i < length(value); i++) {
|
||||
let digit = index('0123456789abcdef', substr(value, i, 1));
|
||||
|
||||
if (digit < 0)
|
||||
return null;
|
||||
|
||||
result = result * 16 + digit;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeChunkedBody(body) {
|
||||
let offset = 0;
|
||||
let decoded = '';
|
||||
|
||||
while (offset < length(body)) {
|
||||
let line_end = index(substr(body, offset), "\r\n");
|
||||
|
||||
if (line_end < 0)
|
||||
return null;
|
||||
|
||||
let size = parseHex(substr(body, offset, line_end));
|
||||
if (size === null)
|
||||
return null;
|
||||
|
||||
offset += line_end + 2;
|
||||
|
||||
if (size === 0)
|
||||
return decoded;
|
||||
|
||||
if (length(body) < offset + size + 2)
|
||||
return null;
|
||||
|
||||
decoded += substr(body, offset, size);
|
||||
offset += size + 2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rebuildResponse(response, body) {
|
||||
let headers = [];
|
||||
|
||||
for (let i = 0; i < length(response.headers); i++) {
|
||||
let line = response.headers[i];
|
||||
|
||||
if (i > 0 && match(lc(line), /^(content-length|transfer-encoding):/))
|
||||
continue;
|
||||
|
||||
push(headers, line);
|
||||
}
|
||||
|
||||
push(headers, 'Content-Length: ' + length(body));
|
||||
|
||||
return join("\r\n", headers) + "\r\n\r\n" + body;
|
||||
}
|
||||
|
||||
function filterResponse(raw) {
|
||||
let response = parseHttpMessage(raw, true);
|
||||
|
||||
if (response === null)
|
||||
return raw;
|
||||
|
||||
let body = response.body;
|
||||
if (lc(headerValue(response.headers, 'transfer-encoding') || '') === 'chunked') {
|
||||
body = decodeChunkedBody(substr(raw, response.complete_len - length(response.body)));
|
||||
if (body === null)
|
||||
return raw;
|
||||
}
|
||||
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = json(body);
|
||||
} catch (e) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (type(payload) !== 'object')
|
||||
return raw;
|
||||
|
||||
payload = filterProxiesPayload(payload);
|
||||
|
||||
return rebuildResponse(response, sprintf('%J', payload));
|
||||
}
|
||||
|
||||
function responseBody(response) {
|
||||
if (response === null)
|
||||
return null;
|
||||
|
||||
let body = response.body;
|
||||
|
||||
if (lc(headerValue(response.headers, 'transfer-encoding') || '') === 'chunked') {
|
||||
body = decodeChunkedBody(response.body);
|
||||
if (body === null)
|
||||
return null;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function parseJsonBody(raw) {
|
||||
if (raw === null)
|
||||
return null;
|
||||
|
||||
let response = parseHttpMessage(raw, true),
|
||||
body = responseBody(response);
|
||||
|
||||
if (body === null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return json(body);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function responseStatus(raw) {
|
||||
let response = parseHttpMessage(raw, true);
|
||||
|
||||
if (response === null)
|
||||
return 0;
|
||||
|
||||
let matched = match(response.headers[0] || '', /^HTTP\/[0-9.]+[ \t]+([0-9]+)/);
|
||||
|
||||
return matched ? int(matched[1]) : 0;
|
||||
}
|
||||
|
||||
function responseComplete(raw) {
|
||||
let header_end = index(raw, "\r\n\r\n");
|
||||
if (header_end < 0)
|
||||
return false;
|
||||
|
||||
let headers = split(substr(raw, 0, header_end), "\r\n");
|
||||
if (lc(headerValue(headers, 'transfer-encoding') || '') === 'chunked')
|
||||
return decodeChunkedBody(substr(raw, header_end + 4)) !== null;
|
||||
|
||||
if (contentLength(headers) === null)
|
||||
return false;
|
||||
|
||||
return parseHttpMessage(raw) !== null;
|
||||
}
|
||||
|
||||
function closeConnection(conn) {
|
||||
if (!conn)
|
||||
return;
|
||||
|
||||
if (conn.client_handle) {
|
||||
conn.client_handle.delete();
|
||||
conn.client_handle = null;
|
||||
}
|
||||
|
||||
if (conn.upstream_handle) {
|
||||
conn.upstream_handle.delete();
|
||||
conn.upstream_handle = null;
|
||||
}
|
||||
|
||||
if (conn.timer) {
|
||||
conn.timer.cancel();
|
||||
conn.timer = null;
|
||||
}
|
||||
|
||||
if (conn.idle_timer) {
|
||||
conn.idle_timer.cancel();
|
||||
conn.idle_timer = null;
|
||||
}
|
||||
|
||||
if (conn.client) {
|
||||
conn.client.close();
|
||||
conn.client = null;
|
||||
}
|
||||
|
||||
if (conn.upstream) {
|
||||
conn.upstream.close();
|
||||
conn.upstream = null;
|
||||
}
|
||||
|
||||
delete connections[conn.id];
|
||||
}
|
||||
|
||||
function isEmptyObject(value) {
|
||||
return type(value) === 'object' && length(value) === 0;
|
||||
}
|
||||
|
||||
function buildJsonResponse(request, payload) {
|
||||
let body = sprintf('%J', payload),
|
||||
headers = [
|
||||
'HTTP/1.1 200 OK',
|
||||
'Content-Type: application/json',
|
||||
'Connection: close'
|
||||
],
|
||||
origin = headerValue(request.headers, 'Origin');
|
||||
|
||||
if (!isEmpty(origin)) {
|
||||
let allowed_origins = toArray(uci.get(uciconfig, ucimain, 'clash_api_allow_origin'));
|
||||
|
||||
if (index(allowed_origins, origin) >= 0) {
|
||||
push(headers, 'Access-Control-Allow-Origin: ' + origin);
|
||||
push(headers, 'Vary: Origin');
|
||||
}
|
||||
}
|
||||
|
||||
push(headers, 'Content-Length: ' + length(body));
|
||||
|
||||
return join("\r\n", headers) + "\r\n\r\n" + body;
|
||||
}
|
||||
|
||||
function parseGroupDelayPath(path) {
|
||||
let parts = split(path, '?', 2),
|
||||
matched = match(parts[0] || '', /^\/group\/(.+)\/delay$/);
|
||||
|
||||
if (!matched)
|
||||
return null;
|
||||
|
||||
return {
|
||||
name: urldecode(matched[1]),
|
||||
query: (length(parts) > 1) ? ('?' + parts[1]) : ''
|
||||
};
|
||||
}
|
||||
|
||||
function upstreamHeader(request, name) {
|
||||
let value = headerValue(request.headers, name);
|
||||
return isEmpty(value) ? null : value;
|
||||
}
|
||||
|
||||
function upstreamRequest(method, path, request, body) {
|
||||
body = body || '';
|
||||
|
||||
let headers = [
|
||||
sprintf('%s %s HTTP/1.1', method, path),
|
||||
sprintf('Host: %s:%d', target.host, target.port),
|
||||
'Accept-Encoding: identity',
|
||||
'Connection: close'
|
||||
],
|
||||
authorization = upstreamHeader(request, 'Authorization'),
|
||||
origin = upstreamHeader(request, 'Origin');
|
||||
|
||||
if (authorization)
|
||||
push(headers, 'Authorization: ' + authorization);
|
||||
|
||||
if (origin)
|
||||
push(headers, 'Origin: ' + origin);
|
||||
|
||||
if (length(body)) {
|
||||
let content_type = upstreamHeader(request, 'Content-Type');
|
||||
|
||||
if (content_type)
|
||||
push(headers, 'Content-Type: ' + content_type);
|
||||
|
||||
push(headers, 'Content-Length: ' + length(body));
|
||||
}
|
||||
|
||||
return join("\r\n", headers) + "\r\n\r\n" + body;
|
||||
}
|
||||
|
||||
function fetchUpstreamAsync(method, path, request, body, done) {
|
||||
let upstream = socket.connect(target.host, target.port, null, 3000);
|
||||
|
||||
if (upstream === null) {
|
||||
done(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
let fetch = {
|
||||
upstream: upstream,
|
||||
handle: null,
|
||||
timer: null,
|
||||
raw: '',
|
||||
finished: false
|
||||
};
|
||||
|
||||
function finish(raw) {
|
||||
if (fetch.finished)
|
||||
return;
|
||||
|
||||
fetch.finished = true;
|
||||
|
||||
if (fetch.handle) {
|
||||
fetch.handle.delete();
|
||||
fetch.handle = null;
|
||||
}
|
||||
|
||||
if (fetch.timer) {
|
||||
fetch.timer.cancel();
|
||||
fetch.timer = null;
|
||||
}
|
||||
|
||||
if (fetch.upstream) {
|
||||
fetch.upstream.close();
|
||||
fetch.upstream = null;
|
||||
}
|
||||
|
||||
done(raw);
|
||||
}
|
||||
|
||||
if (!sendAll(upstream, upstreamRequest(method, path, request, body))) {
|
||||
finish(null);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
fetch.timer = uloop.timer(upstream_read_timeout * 1000, () => finish(null));
|
||||
fetch.handle = uloop.handle(upstream, (events, eof, error) => {
|
||||
if (events & uloop.ULOOP_READ) {
|
||||
let received = recvAvailable(upstream);
|
||||
|
||||
if (length(received.data)) {
|
||||
if (length(fetch.raw) + length(received.data) > MAX_RESPONSE_BUFFER_SIZE) {
|
||||
warn(sprintf('homeproxy clash api proxy: async fetch response exceeded %d bytes\n', MAX_RESPONSE_BUFFER_SIZE));
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch.raw += received.data;
|
||||
}
|
||||
|
||||
if (responseComplete(fetch.raw) || received.eof) {
|
||||
finish(length(fetch.raw) ? fetch.raw : null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (eof || error)
|
||||
finish(length(fetch.raw) ? fetch.raw : null);
|
||||
}, uloop.ULOOP_READ);
|
||||
|
||||
return fetch;
|
||||
}
|
||||
|
||||
function fetchVisibleProxyGroupAsync(group_name, request, done) {
|
||||
fetchUpstreamAsync('GET', '/proxies', request, null, (raw) => {
|
||||
if (raw === null) {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = filterProxiesPayload(parseJsonBody(raw));
|
||||
if (type(payload) !== 'object' || type(payload.proxies) !== 'object') {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let group = payload.proxies[group_name];
|
||||
done((type(group) === 'object') ? group : null);
|
||||
});
|
||||
}
|
||||
|
||||
function testProxyDelayAsync(proxy_name, query, request, done) {
|
||||
fetchUpstreamAsync('GET',
|
||||
'/proxies/' + pathSegment(proxy_name) + '/delay' + query,
|
||||
request,
|
||||
null,
|
||||
(raw) => {
|
||||
if (raw === null || responseStatus(raw) < 200 || responseStatus(raw) >= 300) {
|
||||
done(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = parseJsonBody(raw);
|
||||
|
||||
if (type(payload) === 'object' && type(payload.delay) === 'double') {
|
||||
done(int(payload.delay));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type(payload) === 'object' && type(payload.delay) === 'int') {
|
||||
done(payload.delay);
|
||||
return;
|
||||
}
|
||||
|
||||
done(0);
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackGroupDelayAsync(group_info, request, progress, done) {
|
||||
fetchVisibleProxyGroupAsync(group_info.name, request, (group) => {
|
||||
if (progress)
|
||||
progress();
|
||||
|
||||
if (group === null || type(group.all) !== 'array') {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let results = {},
|
||||
candidates = [],
|
||||
truncated = false;
|
||||
|
||||
for (let proxy_name in group.all) {
|
||||
if (isShadowTlsTag(proxy_name))
|
||||
continue;
|
||||
|
||||
if (length(candidates) >= fallback_delay_limit) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
push(candidates, proxy_name);
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
warn(sprintf('homeproxy clash api proxy: fallback delay for group %s limited to first %d visible proxies\n',
|
||||
group_info.name, fallback_delay_limit));
|
||||
|
||||
function next(index) {
|
||||
if (index >= length(candidates)) {
|
||||
done(results);
|
||||
return;
|
||||
}
|
||||
|
||||
let proxy_name = candidates[index];
|
||||
testProxyDelayAsync(proxy_name, group_info.query, request, (delay) => {
|
||||
results[proxy_name] = delay;
|
||||
|
||||
if (progress)
|
||||
progress();
|
||||
|
||||
next(index + 1);
|
||||
});
|
||||
}
|
||||
|
||||
next(0);
|
||||
});
|
||||
}
|
||||
|
||||
function finishGroupDelay(conn, request, group_info, raw) {
|
||||
if (!connections[conn.id])
|
||||
return;
|
||||
|
||||
let payload = parseJsonBody(raw),
|
||||
status = raw === null ? 0 : responseStatus(raw);
|
||||
|
||||
if (type(payload) === 'object')
|
||||
payload = filterProxiesPayload(payload);
|
||||
|
||||
if (status >= 200 && status < 300 && !isEmptyObject(payload)) {
|
||||
sendAll(conn.client, buildJsonResponse(request, payload));
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
resetTimer(conn);
|
||||
fallbackGroupDelayAsync(group_info, request, () => resetTimer(conn), (fallback_payload) => {
|
||||
if (!connections[conn.id])
|
||||
return;
|
||||
|
||||
if (fallback_payload !== null) {
|
||||
sendAll(conn.client, buildJsonResponse(request, fallback_payload));
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (raw !== null)
|
||||
sendAll(conn.client, raw);
|
||||
|
||||
closeConnection(conn);
|
||||
});
|
||||
}
|
||||
|
||||
function handleGroupDelay(conn, request, group_info) {
|
||||
if (conn.client_handle) {
|
||||
conn.client_handle.delete();
|
||||
conn.client_handle = null;
|
||||
}
|
||||
|
||||
conn.request_buffer = '';
|
||||
conn.timer = uloop.timer(filter_timeout, () => closeConnection(conn));
|
||||
fetchUpstreamAsync(request.method, request.path, request, request.body,
|
||||
(raw) => finishGroupDelay(conn, request, group_info, raw));
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetTimer(conn) {
|
||||
if (conn.timer)
|
||||
conn.timer.set(filter_timeout);
|
||||
}
|
||||
|
||||
function relayRead(conn, from, to) {
|
||||
let received = recvAvailable(from);
|
||||
|
||||
if (length(received.data) && !sendAll(to, received.data)) {
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset idle timer on data activity
|
||||
if (conn.idle_timer && length(received.data) > 0) {
|
||||
conn.idle_timer.set(CLIENT_IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
if (received.eof)
|
||||
closeConnection(conn);
|
||||
}
|
||||
|
||||
function setupRelay(conn) {
|
||||
// Set idle timer for relay phase (30s idle timeout)
|
||||
conn.idle_timer = uloop.timer(CLIENT_IDLE_TIMEOUT, () => {
|
||||
warn(`Connection ${conn.id} idle timeout during relay\n`);
|
||||
closeConnection(conn);
|
||||
});
|
||||
|
||||
conn.client_handle = uloop.handle(conn.client, (events, eof, error) => {
|
||||
if (events & uloop.ULOOP_READ)
|
||||
relayRead(conn, conn.client, conn.upstream);
|
||||
|
||||
if (eof || error)
|
||||
closeConnection(conn);
|
||||
}, uloop.ULOOP_READ);
|
||||
|
||||
conn.upstream_handle = uloop.handle(conn.upstream, (events, eof, error) => {
|
||||
if (events & uloop.ULOOP_READ)
|
||||
relayRead(conn, conn.upstream, conn.client);
|
||||
|
||||
if (eof || error)
|
||||
closeConnection(conn);
|
||||
}, uloop.ULOOP_READ);
|
||||
}
|
||||
|
||||
function finishFilteredResponse(conn) {
|
||||
sendAll(conn.client, filterResponse(conn.response_buffer));
|
||||
closeConnection(conn);
|
||||
}
|
||||
|
||||
function setupFilteredResponse(conn) {
|
||||
conn.response_buffer = '';
|
||||
conn.timer = uloop.timer(filter_timeout, () => closeConnection(conn));
|
||||
|
||||
conn.upstream_handle = uloop.handle(conn.upstream, (events, eof, error) => {
|
||||
if (events & uloop.ULOOP_READ) {
|
||||
let received = recvAvailable(conn.upstream);
|
||||
|
||||
if (length(received.data)) {
|
||||
// Check response buffer size limit
|
||||
if (length(conn.response_buffer) + length(received.data) > MAX_RESPONSE_BUFFER_SIZE) {
|
||||
warn(`Connection ${conn.id} response buffer exceeded ${MAX_RESPONSE_BUFFER_SIZE} bytes\n`);
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
conn.response_buffer += received.data;
|
||||
resetTimer(conn);
|
||||
}
|
||||
|
||||
if (responseComplete(conn.response_buffer) || received.eof) {
|
||||
finishFilteredResponse(conn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (eof || error) {
|
||||
if (length(conn.response_buffer))
|
||||
finishFilteredResponse(conn);
|
||||
else
|
||||
closeConnection(conn);
|
||||
}
|
||||
}, uloop.ULOOP_READ);
|
||||
}
|
||||
|
||||
function startUpstream(conn, request) {
|
||||
try {
|
||||
let group_delay = parseGroupDelayPath(request.path);
|
||||
|
||||
if (request.method === 'GET' && group_delay !== null)
|
||||
return handleGroupDelay(conn, request, group_delay);
|
||||
|
||||
if (conn.client_handle) {
|
||||
conn.client_handle.delete();
|
||||
conn.client_handle = null;
|
||||
}
|
||||
|
||||
conn.filter_read = shouldFilter(request.method, request.path);
|
||||
conn.upstream = socket.connect(target.host, target.port, null, 3000);
|
||||
|
||||
if (conn.upstream === null) {
|
||||
warn(sprintf('homeproxy clash api proxy: connect to %s:%d failed: %s\n',
|
||||
target.host, target.port, socket.error()));
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
let upstream_request = (conn.filter_read || !isUpgradeRequest(request))
|
||||
? rebuildCloseRequest(request)
|
||||
: conn.request_buffer;
|
||||
if (!sendAll(conn.upstream, upstream_request)) {
|
||||
warn('homeproxy clash api proxy: failed to send upstream request\n');
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
conn.request_buffer = '';
|
||||
|
||||
if (conn.filter_read)
|
||||
setupFilteredResponse(conn);
|
||||
else
|
||||
setupRelay(conn);
|
||||
} catch (e) {
|
||||
warn(sprintf('homeproxy clash api proxy: start upstream exception: %J\n', e));
|
||||
closeConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
function onClientRequest(conn, events, eof, error) {
|
||||
if (events & uloop.ULOOP_READ) {
|
||||
let received = recvAvailable(conn.client);
|
||||
|
||||
if (length(received.data)) {
|
||||
// Check request buffer size limit
|
||||
if (length(conn.request_buffer) + length(received.data) > MAX_REQUEST_BUFFER_SIZE) {
|
||||
warn(`Connection ${conn.id} request buffer exceeded ${MAX_REQUEST_BUFFER_SIZE} bytes\n`);
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
conn.request_buffer += received.data;
|
||||
}
|
||||
|
||||
let request = parseHttpMessage(conn.request_buffer);
|
||||
if (request !== null) {
|
||||
// Cancel idle timer when request is complete
|
||||
if (conn.idle_timer) {
|
||||
conn.idle_timer.cancel();
|
||||
delete conn.idle_timer;
|
||||
}
|
||||
startUpstream(conn, request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (received.eof) {
|
||||
closeConnection(conn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (eof || error)
|
||||
closeConnection(conn);
|
||||
}
|
||||
|
||||
function acceptClients(server) {
|
||||
while (true) {
|
||||
// Check connection limit
|
||||
let active_connections = length(keys(connections));
|
||||
if (active_connections >= MAX_CONNECTIONS) {
|
||||
// Accept and immediately close to reject (don't leave in backlog)
|
||||
let rejected = server.accept();
|
||||
if (rejected) {
|
||||
rejected.close();
|
||||
}
|
||||
warn(`Connection limit reached: ${active_connections}/${MAX_CONNECTIONS}\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
let client = server.accept();
|
||||
|
||||
if (client === null)
|
||||
break;
|
||||
|
||||
let conn = {
|
||||
id: next_id++,
|
||||
client,
|
||||
upstream: null,
|
||||
client_handle: null,
|
||||
upstream_handle: null,
|
||||
timer: null,
|
||||
request_buffer: '',
|
||||
response_buffer: '',
|
||||
created_at: time()
|
||||
};
|
||||
|
||||
connections[conn.id] = conn;
|
||||
|
||||
// Set idle timeout (uloop.timer signature: timeout_ms, callback)
|
||||
conn.idle_timer = uloop.timer(CLIENT_IDLE_TIMEOUT, () => {
|
||||
warn(`Connection ${conn.id} idle timeout\n`);
|
||||
closeConnection(conn);
|
||||
});
|
||||
|
||||
conn.client_handle = uloop.handle(client, (events, eof, error) => onClientRequest(conn, events, eof, error), uloop.ULOOP_READ);
|
||||
}
|
||||
}
|
||||
|
||||
const clash_api_enabled = uci.get(uciconfig, ucimain, 'clash_api_enabled') || '0';
|
||||
|
||||
if (clash_api_enabled !== '1')
|
||||
exit(0);
|
||||
|
||||
target = parse_controller(uci.get(uciconfig, ucimain, 'clash_api_external_controller') || '127.0.0.1:9090', 9090);
|
||||
listen = isEmpty(uci.get(uciconfig, ucimain, 'clash_api_proxy_external_controller'))
|
||||
? deriveProxyController((index(target.host, ':') >= 0 ? '[' + target.host + ']' : target.host) + ':' + target.port)
|
||||
: parse_controller(uci.get(uciconfig, ucimain, 'clash_api_proxy_external_controller'), 9091);
|
||||
|
||||
if (isEmpty(listen.host))
|
||||
listen.host = '127.0.0.1';
|
||||
|
||||
server = socket.listen(listen.host, listen.port, null, 128, true);
|
||||
|
||||
if (server === null) {
|
||||
warn(sprintf('homeproxy clash api proxy: failed to listen on %s:%d: %s\n', listen.host, listen.port, socket.error()));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
uloop.init();
|
||||
uloop.handle(server, () => acceptClients(server), uloop.ULOOP_READ);
|
||||
|
||||
warn(sprintf('homeproxy clash api proxy: listening on %s:%d, forwarding to %s:%d\n',
|
||||
listen.host, listen.port, target.host, target.port));
|
||||
|
||||
uloop.run();
|
||||
@@ -57,25 +57,17 @@ const uci = cursor();
|
||||
uci.load(cfgname);
|
||||
|
||||
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
|
||||
let outbound_node, outbound_udp_node, default_outbound, china_dns_server, bypass_cn_traffic;
|
||||
|
||||
default_outbound = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
|
||||
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
|
||||
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
|
||||
if (isEmpty(outbound_node))
|
||||
outbound_node = default_outbound;
|
||||
} else {
|
||||
outbound_node = default_outbound;
|
||||
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
|
||||
}
|
||||
|
||||
const use_default_outbound_routing = (routing_mode !== 'custom') &&
|
||||
isEmpty(uci.get(cfgname, 'config', 'main_node') || 'nil') &&
|
||||
!isEmpty(default_outbound);
|
||||
|
||||
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';
|
||||
@@ -92,7 +84,7 @@ if (match(proxy_mode, /redirect/)) {
|
||||
redirect_port = uci.get(cfgname, 'infra', 'redirect_port') || '5331';
|
||||
}
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing) {
|
||||
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
|
||||
tproxy_port = uci.get(cfgname, 'infra', 'tproxy_port') || '5332';
|
||||
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101');
|
||||
}
|
||||
@@ -384,7 +376,7 @@ chain homeproxy_output_redir {
|
||||
{% endif %}
|
||||
|
||||
{# UDP tproxy #}
|
||||
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing)): %}
|
||||
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %}
|
||||
chain homeproxy_mangle_tproxy {
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
|
||||
{% if (ipv6_support === '1'): %}
|
||||
|
||||
@@ -15,9 +15,10 @@ const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainl
|
||||
|
||||
let outbound_node, tun_name;
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
if (outbound_node === 'nil')
|
||||
if (routing_mode === 'custom')
|
||||
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
else
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
|
||||
if (outbound_node !== 'nil')
|
||||
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -67,7 +67,7 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
|
||||
password: cfg.hysteria_obfs_password
|
||||
} : cfg.hysteria_obfs_password,
|
||||
recv_window_conn: strToInt(cfg.hysteria_recv_window_conn),
|
||||
recv_window_client: strToInt(cfg.hysteria_revc_window_client),
|
||||
recv_window_client: strToInt(cfg.hysteria_recv_window_client),
|
||||
max_conn_client: strToInt(cfg.hysteria_max_conn_client),
|
||||
disable_mtu_discovery: strToBool(cfg.hysteria_disable_mtu_discovery),
|
||||
ignore_client_bandwidth: strToBool(cfg.hysteria_ignore_client_bandwidth),
|
||||
|
||||
Regular → Executable
+18
-21
@@ -66,37 +66,34 @@ export function getTime(epoch) {
|
||||
|
||||
};
|
||||
|
||||
export function wGET(url, ua) {
|
||||
const result = wGETResult(url, ua);
|
||||
return result?.stdout;
|
||||
};
|
||||
|
||||
export function wGETResult(url, ua) {
|
||||
export function wGETResponse(url, ua) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return null;
|
||||
|
||||
if (!ua)
|
||||
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
|
||||
|
||||
let last_result = null;
|
||||
const output = executeCommand(`/usr/bin/wget -O- -S --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
|
||||
return {
|
||||
body: trim(output.stdout),
|
||||
headers: output.stderr || '',
|
||||
exitcode: output.exitcode
|
||||
};
|
||||
};
|
||||
|
||||
for (let extra in [ '', '-4' ]) {
|
||||
const output = executeCommand(`/usr/bin/wget -q ${extra} -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
|
||||
export function wGET(url, ua) {
|
||||
return wGETResponse(url, ua)?.body;
|
||||
};
|
||||
|
||||
output.stdout = trim(output.stdout);
|
||||
output.stderr = trim(output.stderr);
|
||||
last_result = output;
|
||||
export function wGETHeaders(url, ua) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return null;
|
||||
|
||||
if (output.exitcode === 0 && output.stdout)
|
||||
return output;
|
||||
}
|
||||
if (!ua)
|
||||
ua = 'clash.meta';
|
||||
|
||||
if (last_result) {
|
||||
last_result.stdout = trim(last_result.stdout);
|
||||
last_result.stderr = trim(last_result.stderr);
|
||||
}
|
||||
|
||||
return last_result;
|
||||
const output = executeCommand(`/usr/bin/wget -O /dev/null -S --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
|
||||
return output.stderr || '';
|
||||
};
|
||||
/* Utilities end */
|
||||
|
||||
|
||||
@@ -7,21 +7,34 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { popen } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { executeCommand, isEmpty, parseURL, validation } from 'homeproxy';
|
||||
import {
|
||||
format_controller,
|
||||
is_portable_bind_host,
|
||||
local_ipv4_addresses,
|
||||
normalize_local_controller_option,
|
||||
parse_controller
|
||||
} from '/etc/homeproxy/scripts/clash_api.uc';
|
||||
import { isEmpty, parseURL } from 'homeproxy';
|
||||
|
||||
const uci = cursor();
|
||||
|
||||
const uciconfig = 'homeproxy';
|
||||
uci.load(uciconfig);
|
||||
|
||||
function random_secret() {
|
||||
const fd = popen('tr -dc "A-Za-z0-9" < /dev/urandom 2>/dev/null | head -c 16');
|
||||
let random = '';
|
||||
if (fd) {
|
||||
random = trim(fd.read('all') || '');
|
||||
fd.close();
|
||||
}
|
||||
|
||||
if (isEmpty(random)) {
|
||||
const fallback = popen('awk \'BEGIN{srand(); printf "%06d", int(rand() * 1000000)}\'');
|
||||
if (fallback) {
|
||||
random = trim(fallback.read('all') || '');
|
||||
fallback.close();
|
||||
}
|
||||
}
|
||||
|
||||
return random;
|
||||
}
|
||||
|
||||
const uciinfra = 'infra',
|
||||
ucimigration = 'migration',
|
||||
ucimain = 'config',
|
||||
@@ -32,35 +45,14 @@ const uciinfra = 'infra',
|
||||
ucirouting = 'routing',
|
||||
uciroutingnode = 'routing_node',
|
||||
uciroutingrule = 'routing_rule',
|
||||
ucisubscription = 'subscription',
|
||||
uciclashapi = 'clash_api',
|
||||
ucintp = 'ntp',
|
||||
ucicache = 'cache',
|
||||
uciserver = 'server';
|
||||
|
||||
/* Generate a random 256-bit hex secret for the Clash API. Returns null when no
|
||||
* usable randomness source is available, in which case the caller leaves the
|
||||
* secret unset rather than installing a predictable value. */
|
||||
function generateClashSecret() {
|
||||
let secret = trim(executeCommand("head -c 32 /dev/urandom | hexdump -v -e '1/1 \"%02x\"'")?.stdout || '');
|
||||
if (!match(secret, /^[a-f0-9]{64}$/))
|
||||
secret = trim(executeCommand('tr -dc a-f0-9 < /dev/urandom | head -c 64')?.stdout || '');
|
||||
|
||||
return match(secret, /^[a-f0-9]{32,}$/) ? secret : null;
|
||||
}
|
||||
|
||||
function normalizeLocalControllerOptions() {
|
||||
let local_addresses = local_ipv4_addresses((command) => executeCommand(command)?.stdout || ''),
|
||||
fallback_host = local_addresses[0];
|
||||
|
||||
if (isEmpty(fallback_host))
|
||||
return;
|
||||
|
||||
/* clash_api_external_controller is intentionally pinned to loopback (see
|
||||
* below) and must NOT be rewritten to the LAN address. Only the LAN-facing
|
||||
* display proxy is bound to a reachable local address here. */
|
||||
for (let item in [
|
||||
[ 'clash_api_proxy_external_controller', 9091, true ]
|
||||
])
|
||||
normalize_local_controller_option(uci, uciconfig, ucimain,
|
||||
local_addresses, fallback_host, item[0], item[1], item[2]);
|
||||
function is_update_cron(value) {
|
||||
let matched = match(value || '', /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+([0-7*])$/);
|
||||
return !isEmpty(matched) && int(matched[1]) <= 59 && int(matched[2]) <= 23;
|
||||
}
|
||||
|
||||
/* chinadns-ng has been removed */
|
||||
@@ -72,9 +64,7 @@ const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
|
||||
if (type(china_dns_server) === 'array') {
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
|
||||
} else {
|
||||
if (china_dns_server === 'wan_114')
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
|
||||
else if (match(china_dns_server, /,/))
|
||||
if (match(china_dns_server, /,/))
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
|
||||
}
|
||||
|
||||
@@ -93,6 +83,93 @@ if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
|
||||
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
|
||||
uci.delete(uciconfig, uciinfra, 'tun_gso');
|
||||
|
||||
/* endpoint_independent_nat was removed in sing-box 1.13 */
|
||||
if (!isEmpty(uci.get(uciconfig, ucirouting, 'endpoint_independent_nat')))
|
||||
uci.delete(uciconfig, ucirouting, 'endpoint_independent_nat');
|
||||
|
||||
/* direct outbound proxy_protocol was removed in sing-box 1.13 */
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
if (cfg.type === 'direct' && !isEmpty(cfg.proxy_protocol))
|
||||
uci.delete(uciconfig, cfg['.name'], 'proxy_protocol');
|
||||
});
|
||||
|
||||
const legacy_panel_url = 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip';
|
||||
const legacy_panel_proxy_url = 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip';
|
||||
const old_panel_url = 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip';
|
||||
const old_panel_proxy_url = 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip';
|
||||
const default_panel_url = 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip';
|
||||
|
||||
/* clash api panel options were introduced */
|
||||
if (!uci.get(uciconfig, uciclashapi))
|
||||
uci.set(uciconfig, uciclashapi, uciconfig);
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciclashapi, 'external_ui')))
|
||||
uci.set(uciconfig, uciclashapi, 'external_ui', '/etc/homeproxy/run/ui');
|
||||
|
||||
const panel_url = uci.get(uciconfig, uciclashapi, 'external_ui_download_url');
|
||||
if (isEmpty(panel_url) || panel_url === legacy_panel_url || panel_url === legacy_panel_proxy_url || panel_url === old_panel_proxy_url)
|
||||
uci.set(uciconfig, uciclashapi, 'external_ui_download_url', default_panel_url);
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciclashapi, 'external_ui_download_detour')))
|
||||
uci.set(uciconfig, uciclashapi, 'external_ui_download_detour', 'direct-out');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciclashapi, 'external_controller')))
|
||||
uci.set(uciconfig, uciclashapi, 'external_controller', '0.0.0.0:9095');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciclashapi, 'secret')) || uci.get(uciconfig, uciclashapi, 'secret') === 'homeproxy') {
|
||||
let random = random_secret();
|
||||
if (!isEmpty(random))
|
||||
uci.set(uciconfig, uciclashapi, 'secret', random);
|
||||
}
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciclashapi, 'default_mode')))
|
||||
uci.set(uciconfig, uciclashapi, 'default_mode', 'rule');
|
||||
|
||||
/* ntp options were moved into a dedicated section */
|
||||
if (!uci.get(uciconfig, ucintp))
|
||||
uci.set(uciconfig, ucintp, uciconfig);
|
||||
|
||||
const legacy_ntp_server = uci.get(uciconfig, uciinfra, 'ntp_server');
|
||||
if (isEmpty(uci.get(uciconfig, ucintp, 'enabled')))
|
||||
uci.set(uciconfig, ucintp, 'enabled', '1');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucintp, 'server')))
|
||||
uci.set(uciconfig, ucintp, 'server', !isEmpty(legacy_ntp_server) ? legacy_ntp_server : 'ntp.aliyun.com');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucintp, 'server_port')))
|
||||
uci.set(uciconfig, ucintp, 'server_port', '123');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucintp, 'interval')))
|
||||
uci.set(uciconfig, ucintp, 'interval', '30m');
|
||||
|
||||
/* cache file options were moved into a dedicated section */
|
||||
if (!uci.get(uciconfig, ucicache))
|
||||
uci.set(uciconfig, ucicache, uciconfig);
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucicache, 'enabled')))
|
||||
uci.set(uciconfig, ucicache, 'enabled', '1');
|
||||
|
||||
const old_cache_path = '/var/run/homeproxy/cache.db';
|
||||
const default_cache_path = '/etc/homeproxy/cache.db';
|
||||
const cache_path = uci.get(uciconfig, ucicache, 'path');
|
||||
if (isEmpty(cache_path) || cache_path === old_cache_path)
|
||||
uci.set(uciconfig, ucicache, 'path', default_cache_path);
|
||||
|
||||
if (!isEmpty(uci.get(uciconfig, ucicache, 'store_fakeip')))
|
||||
uci.delete(uciconfig, ucicache, 'store_fakeip');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucicache, 'store_rdrc')))
|
||||
uci.set(uciconfig, ucicache, 'store_rdrc', isEmpty(uci.get(uciconfig, ucidns, 'cache_file_store_rdrc')) ? '1' : uci.get(uciconfig, ucidns, 'cache_file_store_rdrc'));
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucicache, 'rdrc_timeout')) && !isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
|
||||
uci.set(uciconfig, ucicache, 'rdrc_timeout', uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout'));
|
||||
|
||||
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_store_rdrc')))
|
||||
uci.delete(uciconfig, ucidns, 'cache_file_store_rdrc');
|
||||
|
||||
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
|
||||
uci.delete(uciconfig, ucidns, 'cache_file_rdrc_timeout');
|
||||
|
||||
/* create migration section */
|
||||
if (!uci.get(uciconfig, ucimigration))
|
||||
uci.set(uciconfig, ucimigration, uciconfig);
|
||||
@@ -111,55 +188,9 @@ if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
|
||||
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
|
||||
uci.set(uciconfig, uciserver, 'log_level', 'warn');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback')))
|
||||
uci.set(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback', '0');
|
||||
|
||||
/* Clash API dashboard integration */
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_enabled')))
|
||||
uci.set(uciconfig, ucimain, 'clash_api_enabled', '1');
|
||||
|
||||
/* Bind sing-box's own Clash API controller to loopback so it is never
|
||||
* reachable directly from the LAN; dashboards reach it only through the
|
||||
* filtering display proxy. Existing LAN-bound controllers are migrated to
|
||||
* 127.0.0.1 once (port preserved); a later deliberate change is left alone. */
|
||||
const clash_external = uci.get(uciconfig, ucimain, 'clash_api_external_controller');
|
||||
if (isEmpty(clash_external)) {
|
||||
uci.set(uciconfig, ucimain, 'clash_api_external_controller', '127.0.0.1:9090');
|
||||
} else if (isEmpty(uci.get(uciconfig, ucimigration, 'clash_api_localhost'))) {
|
||||
const clash_controller = parse_controller(clash_external, 9090);
|
||||
if (!is_portable_bind_host(clash_controller.host))
|
||||
uci.set(uciconfig, ucimain, 'clash_api_external_controller',
|
||||
format_controller('127.0.0.1', clash_controller.port || 9090));
|
||||
}
|
||||
uci.set(uciconfig, ucimigration, 'clash_api_localhost', '1');
|
||||
|
||||
/* sing-box's Clash API performs NO authentication when the secret is empty,
|
||||
* and the LAN-facing display proxy forwards to it, so anyone able to reach the
|
||||
* proxy could otherwise control the proxy without credentials. Generate a
|
||||
* random secret when none is configured. */
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_secret'))) {
|
||||
const clash_secret = generateClashSecret();
|
||||
if (!isEmpty(clash_secret))
|
||||
uci.set(uciconfig, ucimain, 'clash_api_secret', clash_secret);
|
||||
}
|
||||
|
||||
normalizeLocalControllerOptions();
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_default_mode')))
|
||||
uci.set(uciconfig, ucimain, 'clash_api_default_mode', 'Rule');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_origin'))) {
|
||||
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubex.github.io');
|
||||
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubexd.pages.dev');
|
||||
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'http://d.metacubex.one');
|
||||
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://yacd.metacubex.one');
|
||||
}
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_private_network')))
|
||||
uci.set(uciconfig, ucimain, 'clash_api_allow_private_network', '1');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'metacubexd_url')))
|
||||
uci.set(uciconfig, ucimain, 'metacubexd_url', 'https://metacubexd.pages.dev/#/overview');
|
||||
if (!isEmpty(uci.get(uciconfig, 'subscription', 'auto_update_time')) &&
|
||||
!is_update_cron(uci.get(uciconfig, 'subscription', 'auto_update_time')))
|
||||
uci.set(uciconfig, 'subscription', 'auto_update_time', '0 0 * * *');
|
||||
|
||||
/* empty value defaults to all ports now */
|
||||
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
|
||||
@@ -325,6 +356,47 @@ uci.foreach(uciconfig, uciroutingrule, (cfg) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* routing node options */
|
||||
uci.foreach(uciconfig, uciroutingnode, (cfg) => {
|
||||
if (cfg.node === 'urltest' && !isEmpty(cfg.urltest_nodes)) {
|
||||
let custom_nodes = [],
|
||||
subscription_nodes = [];
|
||||
|
||||
for (let node in (type(cfg.urltest_nodes) === 'array' ? cfg.urltest_nodes : [ cfg.urltest_nodes ])) {
|
||||
let node_cfg = uci.get_all(uciconfig, node);
|
||||
if (isEmpty(node_cfg))
|
||||
continue;
|
||||
|
||||
if (!isEmpty(node_cfg.grouphash))
|
||||
push(subscription_nodes, node);
|
||||
else
|
||||
push(custom_nodes, node);
|
||||
}
|
||||
|
||||
if (isEmpty(cfg.selected_nodes) && !isEmpty(custom_nodes))
|
||||
uci.set(uciconfig, cfg['.name'], 'selected_nodes', custom_nodes);
|
||||
|
||||
if (isEmpty(cfg.subscription_nodes) && !isEmpty(subscription_nodes))
|
||||
uci.set(uciconfig, cfg['.name'], 'subscription_nodes', subscription_nodes);
|
||||
|
||||
uci.delete(uciconfig, cfg['.name'], 'urltest_nodes');
|
||||
}
|
||||
});
|
||||
|
||||
/* rule set options */
|
||||
uci.foreach(uciconfig, uciruleset, (cfg) => {
|
||||
if (isEmpty(cfg.tag))
|
||||
uci.set(uciconfig, cfg['.name'], 'tag', 'cfg-' + cfg['.name'] + '-rule');
|
||||
|
||||
if (cfg.type === 'remote') {
|
||||
if (isEmpty(cfg.auto_update))
|
||||
uci.set(uciconfig, cfg['.name'], 'auto_update', '1');
|
||||
|
||||
if (!is_update_cron(cfg.update_interval))
|
||||
uci.set(uciconfig, cfg['.name'], 'update_interval', '0 0 * * *');
|
||||
}
|
||||
});
|
||||
|
||||
/* server options */
|
||||
/* auto_firewall was moved into server options */
|
||||
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
|
||||
@@ -332,6 +404,13 @@ if (!isEmpty(auto_firewall))
|
||||
uci.delete(uciconfig, uciserver, 'auto_firewall');
|
||||
|
||||
uci.foreach(uciconfig, uciserver, (cfg) => {
|
||||
if (!isEmpty(cfg.hysteria_revc_window_client) && isEmpty(cfg.hysteria_recv_window_client)) {
|
||||
uci.set(uciconfig, cfg['.name'], 'hysteria_recv_window_client', cfg.hysteria_revc_window_client);
|
||||
}
|
||||
|
||||
if (!isEmpty(cfg.hysteria_revc_window_client))
|
||||
uci.delete(uciconfig, cfg['.name'], 'hysteria_revc_window_client');
|
||||
|
||||
/* auto_firewall was moved into server options */
|
||||
if (auto_firewall === '1')
|
||||
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 节点正则资源限制 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const NODE_FILTER_MAX_PATTERN_LENGTH = 512;
|
||||
const NODE_FILTER_MAX_RESULT_NODES = 500;
|
||||
const NODE_FILTER_MAX_SCAN_NODES = 2000;
|
||||
|
||||
function isEmpty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function limits() {
|
||||
return {
|
||||
max_pattern_length: NODE_FILTER_MAX_PATTERN_LENGTH,
|
||||
max_result_nodes: NODE_FILTER_MAX_RESULT_NODES,
|
||||
max_scan_nodes: NODE_FILTER_MAX_SCAN_NODES
|
||||
};
|
||||
}
|
||||
|
||||
function nodeFilterGuard(pattern) {
|
||||
let result = { result: true, limits: limits() };
|
||||
|
||||
if (isEmpty(pattern))
|
||||
return result;
|
||||
|
||||
if (length(pattern) > NODE_FILTER_MAX_PATTERN_LENGTH)
|
||||
return {
|
||||
result: false,
|
||||
error: sprintf('正则长度超过 %d 字符', NODE_FILTER_MAX_PATTERN_LENGTH),
|
||||
limits: limits()
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeNodeList(value) {
|
||||
let nodes = [];
|
||||
|
||||
if (isEmpty(value))
|
||||
return nodes;
|
||||
|
||||
if (type(value) !== 'array')
|
||||
value = [ value ];
|
||||
|
||||
for (let node_id in value)
|
||||
if (!isEmpty(node_id) && !~index(nodes, node_id))
|
||||
push(nodes, node_id);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function compilePattern(pattern) {
|
||||
let validated = nodeFilterGuard(pattern);
|
||||
|
||||
if (!validated.result)
|
||||
return validated;
|
||||
|
||||
if (isEmpty(pattern))
|
||||
return {
|
||||
result: true,
|
||||
pattern: null,
|
||||
limits: validated.limits
|
||||
};
|
||||
|
||||
try {
|
||||
return {
|
||||
result: true,
|
||||
pattern: regexp(pattern),
|
||||
limits: validated.limits
|
||||
};
|
||||
} catch(e) {
|
||||
return {
|
||||
result: false,
|
||||
error: e.message || sprintf('%s', e),
|
||||
limits: validated.limits
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function previewResult(nodes, meta) {
|
||||
return {
|
||||
result: true,
|
||||
nodes: nodes,
|
||||
truncated: meta.truncated,
|
||||
scan_truncated: meta.scan_truncated,
|
||||
max_result_nodes: meta.max_result_nodes,
|
||||
max_scan_nodes: meta.max_scan_nodes
|
||||
};
|
||||
}
|
||||
|
||||
function appendUniqueNode(nodes, node_id) {
|
||||
if (!isEmpty(node_id) && !~index(nodes, node_id))
|
||||
push(nodes, node_id);
|
||||
}
|
||||
|
||||
function appendLimitedNode(nodes, node_id, meta) {
|
||||
if (isEmpty(node_id) || ~index(nodes, node_id))
|
||||
return;
|
||||
|
||||
if (length(nodes) < meta.max_result_nodes)
|
||||
push(nodes, node_id);
|
||||
else
|
||||
meta.truncated = true;
|
||||
}
|
||||
|
||||
function previewManualNodeAllowed(uci, config, node_id, node_mode) {
|
||||
const section_type = uci.get(config, node_id);
|
||||
|
||||
if (section_type === 'node')
|
||||
return true;
|
||||
|
||||
if (node_mode === 'selector') {
|
||||
if (node_id in ['direct-out', 'block-out'])
|
||||
return true;
|
||||
|
||||
if (section_type === 'routing_node' &&
|
||||
uci.get(config, node_id, 'enabled') === '1' &&
|
||||
!isEmpty(uci.get(config, node_id, 'node')))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterManualNodeAllowed(ctx, node_id) {
|
||||
if (type(ctx.allow_manual_node) === 'function')
|
||||
return !!ctx.allow_manual_node(node_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function manualNodeError(ctx, node_id) {
|
||||
if (type(ctx.on_invalid_manual_node) === 'function')
|
||||
ctx.on_invalid_manual_node(node_id);
|
||||
}
|
||||
|
||||
function labelOfNode(cfg) {
|
||||
return cfg.label || cfg['.name'];
|
||||
}
|
||||
|
||||
function scanMatchedNodeIds(ctx, pattern, meta) {
|
||||
let matched = [],
|
||||
scanned = 0;
|
||||
|
||||
if (!pattern)
|
||||
return matched;
|
||||
|
||||
ctx.uci.foreach(ctx.config, ctx.node_type || 'node', (cfg) => {
|
||||
if (scanned >= meta.max_scan_nodes) {
|
||||
meta.scan_truncated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
scanned++;
|
||||
|
||||
if (match(labelOfNode(cfg), pattern))
|
||||
push(matched, cfg['.name']);
|
||||
});
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
function applyExcludePattern(ctx, nodes, pattern, meta) {
|
||||
let exclude_ids = {};
|
||||
|
||||
if (!pattern)
|
||||
return nodes;
|
||||
|
||||
for (let node_id in scanMatchedNodeIds(ctx, pattern, meta))
|
||||
exclude_ids[node_id] = true;
|
||||
|
||||
return filter(nodes, (node_id) => !exclude_ids[node_id]);
|
||||
}
|
||||
|
||||
function defaultMeta() {
|
||||
let guard = nodeFilterGuard(null),
|
||||
guard_limits = guard.limits;
|
||||
|
||||
return {
|
||||
truncated: false,
|
||||
scan_truncated: false,
|
||||
max_result_nodes: guard_limits.max_result_nodes,
|
||||
max_scan_nodes: guard_limits.max_scan_nodes
|
||||
};
|
||||
}
|
||||
|
||||
export function node_filter_guard(pattern) {
|
||||
return nodeFilterGuard(pattern);
|
||||
};
|
||||
|
||||
export function node_filter_compile(pattern) {
|
||||
return compilePattern(pattern);
|
||||
};
|
||||
|
||||
export function expand_node_filter(ctx, manual_nodes, node_filter, node_filter_exclude) {
|
||||
ctx = ctx || {};
|
||||
|
||||
let include_compiled = compilePattern(node_filter),
|
||||
exclude_compiled = compilePattern(node_filter_exclude),
|
||||
meta = defaultMeta(),
|
||||
nodes = [];
|
||||
|
||||
if (!include_compiled.result)
|
||||
return include_compiled;
|
||||
|
||||
if (!exclude_compiled.result)
|
||||
return exclude_compiled;
|
||||
|
||||
for (let node_id in normalizeNodeList(manual_nodes)) {
|
||||
if (filterManualNodeAllowed(ctx, node_id))
|
||||
appendUniqueNode(nodes, node_id);
|
||||
else
|
||||
manualNodeError(ctx, node_id);
|
||||
}
|
||||
|
||||
for (let node_id in scanMatchedNodeIds(ctx, include_compiled.pattern, meta))
|
||||
appendLimitedNode(nodes, node_id, meta);
|
||||
|
||||
nodes = applyExcludePattern(ctx, nodes, exclude_compiled.pattern, meta);
|
||||
|
||||
return previewResult(nodes, meta);
|
||||
};
|
||||
|
||||
export function preview_manual_node_allowed(uci, config, node_id, node_mode) {
|
||||
return previewManualNodeAllowed(uci, config, node_id, node_mode);
|
||||
};
|
||||
|
||||
export function normalize_node_list(value) {
|
||||
return normalizeNodeList(value);
|
||||
};
|
||||
@@ -1,362 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
function isEmpty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (type(value) === 'array')
|
||||
return value;
|
||||
else if (isEmpty(value))
|
||||
return [];
|
||||
|
||||
return [ value ];
|
||||
}
|
||||
|
||||
function removeListValue(uci, config, section, option, value) {
|
||||
let values = toArray(uci.get(config, section, option)),
|
||||
filtered = [];
|
||||
|
||||
for (let item in values)
|
||||
if (item !== value)
|
||||
push(filtered, item);
|
||||
|
||||
if (length(filtered))
|
||||
uci.set(config, section, option, filtered);
|
||||
else if (length(values))
|
||||
uci.delete(config, section, option);
|
||||
}
|
||||
|
||||
function addReference(refs, entry) {
|
||||
push(refs, entry);
|
||||
}
|
||||
|
||||
function addCleanupChange(changes, node_id, scope, section, option, action, value) {
|
||||
push(changes, {
|
||||
node_id: node_id,
|
||||
scope: scope,
|
||||
section: section,
|
||||
option: option,
|
||||
action: action,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUniqueNodeIds(node_ids) {
|
||||
let seen = {},
|
||||
unique_ids = [];
|
||||
|
||||
if (type(node_ids) !== 'array')
|
||||
return unique_ids;
|
||||
|
||||
for (let node_id in node_ids)
|
||||
if (!seen[node_id] && !isEmpty(node_id)) {
|
||||
seen[node_id] = true;
|
||||
push(unique_ids, node_id);
|
||||
}
|
||||
|
||||
return unique_ids;
|
||||
}
|
||||
|
||||
export function collect_node_references(uci, config, node_id) {
|
||||
let refs = [];
|
||||
|
||||
if (isEmpty(node_id))
|
||||
return refs;
|
||||
|
||||
if (uci.get(config, 'config', 'main_node') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'main_node',
|
||||
section: 'config',
|
||||
option: 'main_node',
|
||||
label: 'Main node'
|
||||
});
|
||||
|
||||
if (uci.get(config, 'config', 'main_udp_node') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'main_udp_node',
|
||||
section: 'config',
|
||||
option: 'main_udp_node',
|
||||
label: 'Main UDP node'
|
||||
});
|
||||
|
||||
if (~index(toArray(uci.get(config, 'config', 'main_urltest_nodes')), node_id))
|
||||
addReference(refs, {
|
||||
scope: 'main_urltest_nodes',
|
||||
section: 'config',
|
||||
option: 'main_urltest_nodes',
|
||||
label: 'Main node / URLTest nodes'
|
||||
});
|
||||
|
||||
if (~index(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes')), node_id))
|
||||
addReference(refs, {
|
||||
scope: 'main_udp_urltest_nodes',
|
||||
section: 'config',
|
||||
option: 'main_udp_urltest_nodes',
|
||||
label: 'Main UDP node / URLTest nodes'
|
||||
});
|
||||
|
||||
if (uci.get(config, 'routing', 'default_outbound') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'default_outbound',
|
||||
section: 'routing',
|
||||
option: 'default_outbound',
|
||||
label: 'Routing / Default outbound'
|
||||
});
|
||||
|
||||
uci.foreach(config, 'routing_node', (cfg) => {
|
||||
const section = cfg['.name'],
|
||||
label = cfg.label || section;
|
||||
|
||||
if (uci.get(config, section, 'node') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'routing_node_node',
|
||||
section: section,
|
||||
option: 'node',
|
||||
label: sprintf('Routing Nodes / %s / Node', label)
|
||||
});
|
||||
|
||||
if (uci.get(config, section, 'outbound') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'routing_node_outbound',
|
||||
section: section,
|
||||
option: 'outbound',
|
||||
label: sprintf('Routing Nodes / %s / Outbound', label)
|
||||
});
|
||||
|
||||
if (uci.get(config, section, 'selector_default') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'routing_node_selector_default',
|
||||
section: section,
|
||||
option: 'selector_default',
|
||||
label: sprintf('Routing Nodes / %s / Default', label)
|
||||
});
|
||||
|
||||
if (~index(toArray(uci.get(config, section, 'urltest_nodes')), node_id))
|
||||
addReference(refs, {
|
||||
scope: 'routing_node_urltest_nodes',
|
||||
section: section,
|
||||
option: 'urltest_nodes',
|
||||
label: sprintf('Routing Nodes / %s / URLTest nodes', label)
|
||||
});
|
||||
|
||||
if (~index(toArray(uci.get(config, section, 'selector_nodes')), node_id))
|
||||
addReference(refs, {
|
||||
scope: 'routing_node_selector_nodes',
|
||||
section: section,
|
||||
option: 'selector_nodes',
|
||||
label: sprintf('Routing Nodes / %s / Selector nodes', label)
|
||||
});
|
||||
});
|
||||
|
||||
uci.foreach(config, 'routing_rule', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'routing_rule_outbound',
|
||||
section: cfg['.name'],
|
||||
option: 'outbound',
|
||||
label: sprintf('Routing Rules / %s / Outbound', cfg.label || cfg['.name'])
|
||||
});
|
||||
});
|
||||
|
||||
uci.foreach(config, 'dns_server', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'dns_server_outbound',
|
||||
section: cfg['.name'],
|
||||
option: 'outbound',
|
||||
label: sprintf('DNS Server / %s / Outbound', cfg.label || cfg['.name'])
|
||||
});
|
||||
});
|
||||
|
||||
uci.foreach(config, 'dns_rule', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'dns_rule_outbound',
|
||||
section: cfg['.name'],
|
||||
option: 'outbound',
|
||||
label: sprintf('DNS Rules / %s / Outbound', cfg.label || cfg['.name'])
|
||||
});
|
||||
});
|
||||
|
||||
uci.foreach(config, 'ruleset', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
|
||||
addReference(refs, {
|
||||
scope: 'ruleset_outbound',
|
||||
section: cfg['.name'],
|
||||
option: 'outbound',
|
||||
label: sprintf('Rule set / %s / Outbound', cfg.label || cfg['.name'])
|
||||
});
|
||||
});
|
||||
|
||||
return refs;
|
||||
};
|
||||
|
||||
export function cleanup_node_references_for_delete(uci, config, node_id, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
if (isEmpty(node_id))
|
||||
return { changed: false, changes: [] };
|
||||
|
||||
let changed = false,
|
||||
changes = [],
|
||||
main_node_fallback = isEmpty(opts.main_node_fallback) ? 'nil' : opts.main_node_fallback,
|
||||
main_udp_fallback = isEmpty(opts.main_udp_fallback) ? 'same' : opts.main_udp_fallback;
|
||||
|
||||
if (uci.get(config, 'config', 'main_node') === node_id) {
|
||||
uci.set(config, 'config', 'main_node', main_node_fallback);
|
||||
addCleanupChange(changes, node_id, 'main_node', 'config', 'main_node', 'set', main_node_fallback);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (uci.get(config, 'config', 'main_udp_node') === node_id) {
|
||||
uci.set(config, 'config', 'main_udp_node', main_udp_fallback);
|
||||
addCleanupChange(changes, node_id, 'main_udp_node', 'config', 'main_udp_node', 'set', main_udp_fallback);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let before_main_urltest = length(toArray(uci.get(config, 'config', 'main_urltest_nodes')));
|
||||
removeListValue(uci, config, 'config', 'main_urltest_nodes', node_id);
|
||||
if (length(toArray(uci.get(config, 'config', 'main_urltest_nodes'))) !== before_main_urltest) {
|
||||
addCleanupChange(changes, node_id, 'main_urltest_nodes', 'config', 'main_urltest_nodes', 'remove', null);
|
||||
changed = true;
|
||||
}
|
||||
if (uci.get(config, 'config', 'main_node') === 'urltest' &&
|
||||
!length(toArray(uci.get(config, 'config', 'main_urltest_nodes')))) {
|
||||
uci.set(config, 'config', 'main_node', main_node_fallback);
|
||||
addCleanupChange(changes, node_id, 'main_node', 'config', 'main_node', 'set', main_node_fallback);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let before_main_udp_urltest = length(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes')));
|
||||
removeListValue(uci, config, 'config', 'main_udp_urltest_nodes', node_id);
|
||||
if (length(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes'))) !== before_main_udp_urltest) {
|
||||
addCleanupChange(changes, node_id, 'main_udp_urltest_nodes', 'config', 'main_udp_urltest_nodes', 'remove', null);
|
||||
changed = true;
|
||||
}
|
||||
if (uci.get(config, 'config', 'main_udp_node') === 'urltest' &&
|
||||
!length(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes')))) {
|
||||
uci.set(config, 'config', 'main_udp_node', main_udp_fallback);
|
||||
addCleanupChange(changes, node_id, 'main_udp_node', 'config', 'main_udp_node', 'set', main_udp_fallback);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (uci.get(config, 'routing', 'default_outbound') === node_id) {
|
||||
uci.set(config, 'routing', 'default_outbound', 'nil');
|
||||
addCleanupChange(changes, node_id, 'default_outbound', 'routing', 'default_outbound', 'set', 'nil');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
uci.foreach(config, 'routing_node', (cfg) => {
|
||||
const section = cfg['.name'];
|
||||
|
||||
if (uci.get(config, section, 'node') === node_id) {
|
||||
uci.delete(config, section, 'node');
|
||||
addCleanupChange(changes, node_id, 'routing_node_node', section, 'node', 'delete', null);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (uci.get(config, section, 'outbound') === node_id) {
|
||||
uci.delete(config, section, 'outbound');
|
||||
addCleanupChange(changes, node_id, 'routing_node_outbound', section, 'outbound', 'delete', null);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (uci.get(config, section, 'selector_default') === node_id) {
|
||||
uci.delete(config, section, 'selector_default');
|
||||
addCleanupChange(changes, node_id, 'routing_node_selector_default', section, 'selector_default', 'delete', null);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let before_urltest = length(toArray(uci.get(config, section, 'urltest_nodes')));
|
||||
removeListValue(uci, config, section, 'urltest_nodes', node_id);
|
||||
if (length(toArray(uci.get(config, section, 'urltest_nodes'))) !== before_urltest) {
|
||||
addCleanupChange(changes, node_id, 'routing_node_urltest_nodes', section, 'urltest_nodes', 'remove', null);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let before_selector = length(toArray(uci.get(config, section, 'selector_nodes')));
|
||||
removeListValue(uci, config, section, 'selector_nodes', node_id);
|
||||
if (length(toArray(uci.get(config, section, 'selector_nodes'))) !== before_selector) {
|
||||
addCleanupChange(changes, node_id, 'routing_node_selector_nodes', section, 'selector_nodes', 'remove', null);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
uci.foreach(config, 'routing_rule', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
|
||||
uci.set(config, cfg['.name'], 'outbound', 'block-out');
|
||||
addCleanupChange(changes, node_id, 'routing_rule_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
uci.foreach(config, 'dns_server', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
|
||||
uci.set(config, cfg['.name'], 'outbound', 'block-out');
|
||||
addCleanupChange(changes, node_id, 'dns_server_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
uci.foreach(config, 'dns_rule', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
|
||||
uci.set(config, cfg['.name'], 'outbound', 'block-out');
|
||||
addCleanupChange(changes, node_id, 'dns_rule_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
uci.foreach(config, 'ruleset', (cfg) => {
|
||||
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
|
||||
uci.set(config, cfg['.name'], 'outbound', 'block-out');
|
||||
addCleanupChange(changes, node_id, 'ruleset_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
changed: changed,
|
||||
changes: changes,
|
||||
main_node_fallback: main_node_fallback,
|
||||
main_udp_fallback: main_udp_fallback
|
||||
};
|
||||
};
|
||||
|
||||
export function remove_subscription_nodes(uci, config, node_ids, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
let unique_ids = normalizeUniqueNodeIds(node_ids),
|
||||
removed = 0,
|
||||
changed = false,
|
||||
changes = [],
|
||||
main_node_fallback = isEmpty(opts.main_node_fallback) ? 'nil' : opts.main_node_fallback,
|
||||
main_udp_fallback = isEmpty(opts.main_udp_fallback) ? 'same' : opts.main_udp_fallback;
|
||||
|
||||
for (let node_id in unique_ids) {
|
||||
let cfg = uci.get_all(config, node_id);
|
||||
if (!cfg || cfg['.type'] !== 'node' || isEmpty(cfg.grouphash))
|
||||
continue;
|
||||
|
||||
const cleanup = cleanup_node_references_for_delete(uci, config, node_id, {
|
||||
main_node_fallback: main_node_fallback,
|
||||
main_udp_fallback: main_udp_fallback
|
||||
});
|
||||
if (cleanup.changed) {
|
||||
changed = true;
|
||||
for (let item in cleanup.changes)
|
||||
push(changes, item);
|
||||
}
|
||||
|
||||
uci.delete(config, node_id);
|
||||
removed++;
|
||||
}
|
||||
|
||||
return {
|
||||
changed: changed,
|
||||
removed: removed,
|
||||
changes: changes,
|
||||
main_node_fallback: main_node_fallback,
|
||||
main_udp_fallback: main_udp_fallback
|
||||
};
|
||||
};
|
||||
@@ -1,290 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy outbound tag mapping helpers.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function reserve_tag(used_tags, tag) {
|
||||
if (type(tag) === 'string' && tag !== '')
|
||||
used_tags[tag] = true;
|
||||
}
|
||||
|
||||
function sanitize_outbound_tag(label) {
|
||||
if (type(label) !== 'string')
|
||||
return '';
|
||||
|
||||
let tag = '';
|
||||
for (let i = 0; i < length(label); i++) {
|
||||
let ch = ord(label, i);
|
||||
if (ch >= 32 && ch !== 127)
|
||||
tag += substr(label, i, 1);
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
function conflicts_outbound_tag(tag, used_tags) {
|
||||
return tag in used_tags ||
|
||||
match(tag, /^cfg-.+-out$/) ||
|
||||
match(tag, /^cfg-.+-out-shadowtls$/) ||
|
||||
match(tag, /^cfg-.+-dns$/) ||
|
||||
match(tag, /^cfg-.+-rule$/) ||
|
||||
match(tag, /-out-shadowtls$/);
|
||||
}
|
||||
|
||||
function make_suffixed_outbound_tag(base, section, used_tags) {
|
||||
let prefix_len = (length(section) < 6) ? length(section) : 6;
|
||||
|
||||
for (let i = prefix_len; i <= length(section); i++) {
|
||||
let tag = base + ' #' + substr(section, 0, i);
|
||||
if (!conflicts_outbound_tag(tag, used_tags))
|
||||
return tag;
|
||||
}
|
||||
|
||||
for (let n = 2; ; n++) {
|
||||
let tag = base + ' #' + section + '-' + n;
|
||||
if (!conflicts_outbound_tag(tag, used_tags))
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
function sort_outbound_tag_items(items) {
|
||||
for (let i = 0; i < length(items); i++) {
|
||||
let min = i;
|
||||
|
||||
for (let j = i + 1; j < length(items); j++)
|
||||
if (items[j].section < items[min].section)
|
||||
min = j;
|
||||
|
||||
if (min !== i) {
|
||||
let item = items[i];
|
||||
items[i] = items[min];
|
||||
items[min] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sectionFallbackTag(section) {
|
||||
return 'cfg-' + section + '-out';
|
||||
}
|
||||
|
||||
function shadowtlsFallbackTag(section) {
|
||||
return sectionFallbackTag(section) + '-shadowtls';
|
||||
}
|
||||
|
||||
function shadowtlsTagFromOutbound(tag) {
|
||||
if (type(tag) !== 'string' || tag === '')
|
||||
return tag;
|
||||
|
||||
if (match(tag, /-out$/))
|
||||
return tag + '-shadowtls';
|
||||
|
||||
return tag + '-out-shadowtls';
|
||||
}
|
||||
|
||||
function renameTags(value, rename, rename_string) {
|
||||
let t = type(value);
|
||||
|
||||
if (t === 'string')
|
||||
return (rename_string && value in rename) ? rename[value] : value;
|
||||
|
||||
if (t === 'object') {
|
||||
for (let k in value) {
|
||||
if (k in ['tag', 'outbound', 'outbounds', 'detour', 'default', 'final', 'download_detour'])
|
||||
value[k] = renameTags(value[k], rename, true);
|
||||
else
|
||||
value[k] = renameTags(value[k], rename, false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (t === 'array') {
|
||||
for (let i = 0; i < length(value); i++)
|
||||
value[i] = renameTags(value[i], rename, rename_string);
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectStaleTagReferences(value, rename, rename_string, refs) {
|
||||
let t = type(value);
|
||||
|
||||
if (t === 'string') {
|
||||
if (rename_string && value in rename && rename[value] !== value)
|
||||
refs[value] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (t === 'object') {
|
||||
for (let k in value)
|
||||
collectStaleTagReferences(value[k], rename,
|
||||
k in ['tag', 'outbound', 'outbounds', 'detour', 'default', 'final', 'download_detour'],
|
||||
refs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (t === 'array')
|
||||
for (let i in value)
|
||||
collectStaleTagReferences(i, rename, rename_string, refs);
|
||||
}
|
||||
|
||||
function buildTagRenameMap(tag_map) {
|
||||
let rename = {};
|
||||
|
||||
if (type(tag_map) !== 'object')
|
||||
return rename;
|
||||
|
||||
for (let section in tag_map) {
|
||||
let old_tag = sectionFallbackTag(section),
|
||||
final_tag = tag_map[section];
|
||||
|
||||
rename[old_tag] = final_tag;
|
||||
rename[old_tag + '-shadowtls'] = shadowtlsTagFromOutbound(final_tag);
|
||||
}
|
||||
|
||||
return rename;
|
||||
}
|
||||
|
||||
function checkUniqueTags(entries, seen, on_duplicate) {
|
||||
for (let entry in (entries || [])) {
|
||||
let tag = (type(entry) === 'object') ? entry.tag : null;
|
||||
if (tag === null)
|
||||
continue;
|
||||
|
||||
if (seen[tag] && type(on_duplicate) === 'function')
|
||||
on_duplicate(tag);
|
||||
|
||||
seen[tag] = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function build_outbound_tag_map(uci) {
|
||||
const uciconfig = 'homeproxy';
|
||||
let items = [],
|
||||
used_tags = {},
|
||||
tag_map = {};
|
||||
|
||||
const reserved_tags = [
|
||||
'GLOBAL', 'any',
|
||||
'direct-out', 'block-out', 'main-out', 'main-udp-out',
|
||||
'default-dns', 'system-dns', 'main-dns', 'china-dns',
|
||||
'dns-in', 'mixed-in', 'redirect-in', 'tproxy-in', 'tun-in',
|
||||
'direct-domain', 'proxy-domain', 'geoip-cn', 'geosite-cn', 'geosite-noncn',
|
||||
// 旧隐藏测速组的保留占位,避免用户标签或旧配置与保留名冲突;不是运行时测速组。
|
||||
'__homeproxy_delay_test__'
|
||||
];
|
||||
|
||||
for (let tag in reserved_tags)
|
||||
reserve_tag(used_tags, tag);
|
||||
|
||||
uci.foreach(uciconfig, 'dns_server', (cfg) => {
|
||||
reserve_tag(used_tags, 'cfg-' + cfg['.name'] + '-dns');
|
||||
});
|
||||
|
||||
uci.foreach(uciconfig, 'ruleset', (cfg) => {
|
||||
reserve_tag(used_tags, 'cfg-' + cfg['.name'] + '-rule');
|
||||
});
|
||||
|
||||
uci.foreach(uciconfig, 'node', (cfg) => {
|
||||
push(items, {
|
||||
section: cfg['.name'],
|
||||
label: cfg.label
|
||||
});
|
||||
});
|
||||
|
||||
uci.foreach(uciconfig, 'routing_node', (cfg) => {
|
||||
if (cfg.enabled === '1' && cfg.node in ['selector', 'urltest'])
|
||||
push(items, {
|
||||
section: cfg['.name'],
|
||||
label: cfg.label
|
||||
});
|
||||
});
|
||||
|
||||
sort_outbound_tag_items(items);
|
||||
|
||||
for (let item in items) {
|
||||
let section = item.section,
|
||||
base = sanitize_outbound_tag(item.label),
|
||||
final_tag;
|
||||
|
||||
if (base === '') {
|
||||
final_tag = 'cfg-' + section + '-out';
|
||||
reserve_tag(used_tags, final_tag);
|
||||
tag_map[section] = final_tag;
|
||||
continue;
|
||||
}
|
||||
|
||||
final_tag = conflicts_outbound_tag(base, used_tags)
|
||||
? make_suffixed_outbound_tag(base, section, used_tags)
|
||||
: base;
|
||||
|
||||
reserve_tag(used_tags, final_tag);
|
||||
tag_map[section] = final_tag;
|
||||
}
|
||||
|
||||
return tag_map;
|
||||
};
|
||||
|
||||
export function get_outbound_tag(tag_map, section) {
|
||||
if (type(tag_map) === 'object' && section in tag_map)
|
||||
return tag_map[section];
|
||||
|
||||
return sectionFallbackTag(section);
|
||||
};
|
||||
|
||||
export function get_shadowtls_outbound_tag(tag_map, section) {
|
||||
return shadowtlsTagFromOutbound(get_outbound_tag(tag_map, section));
|
||||
};
|
||||
|
||||
export function get_fallback_outbound_tag(section) {
|
||||
return sectionFallbackTag(section);
|
||||
};
|
||||
|
||||
export function get_fallback_shadowtls_outbound_tag(section) {
|
||||
return shadowtlsFallbackTag(section);
|
||||
};
|
||||
|
||||
export function apply_outbound_tag_rename(client_config, tag_map) {
|
||||
renameTags(client_config, buildTagRenameMap(tag_map), false);
|
||||
return client_config;
|
||||
};
|
||||
|
||||
export function assert_unique_outbound_tags(client_config, on_duplicate) {
|
||||
let seen = {};
|
||||
|
||||
checkUniqueTags(client_config?.outbounds, seen, on_duplicate);
|
||||
checkUniqueTags(client_config?.endpoints, seen, on_duplicate);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function assert_no_stale_outbound_tags(client_config, tag_map, on_stale) {
|
||||
let refs = {},
|
||||
rename = buildTagRenameMap(tag_map);
|
||||
|
||||
collectStaleTagReferences(client_config, rename, false, refs);
|
||||
|
||||
for (let tag in keys(refs))
|
||||
if (type(on_stale) === 'function')
|
||||
on_stale(tag, rename[tag]);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function normalize_outbound_target(target, tag_map) {
|
||||
if (type(target) !== 'string' || target === '')
|
||||
return target;
|
||||
|
||||
if (type(tag_map) === 'object')
|
||||
for (let section in tag_map) {
|
||||
let final_tag = tag_map[section];
|
||||
if (target === final_tag || target === get_fallback_outbound_tag(section))
|
||||
return section;
|
||||
}
|
||||
|
||||
let matched = match(target, /^cfg-(.+)-out$/);
|
||||
return matched ? matched[1] : target;
|
||||
};
|
||||
@@ -1,249 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 路由目标解析 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
get_outbound_tag,
|
||||
normalize_outbound_target as normalizeOutboundTargetValue
|
||||
} from 'outbound_tag';
|
||||
|
||||
function isEmpty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (type(value) === 'array')
|
||||
return value;
|
||||
else if (isEmpty(value))
|
||||
return [];
|
||||
|
||||
return [ value ];
|
||||
}
|
||||
|
||||
function report(ctx, level, message, suggestion) {
|
||||
if (type(ctx.reportError) === 'function')
|
||||
ctx.reportError(level, message, suggestion);
|
||||
}
|
||||
|
||||
function sectionType(ctx, name) {
|
||||
return ctx.uci.get_all(ctx.config, name)?.['.type'];
|
||||
}
|
||||
|
||||
function outboundTag(ctx, section) {
|
||||
return get_outbound_tag(ctx?.tag_map, section);
|
||||
}
|
||||
|
||||
export function is_builtin_outbound(_ctx, target) {
|
||||
return target in ['block-out', 'direct-out'];
|
||||
};
|
||||
|
||||
export function is_node_section(ctx, target) {
|
||||
return sectionType(ctx, target) === (ctx.node_type || 'node');
|
||||
};
|
||||
|
||||
export function is_routing_node_section(ctx, target) {
|
||||
const cfg = ctx.uci.get_all(ctx.config, target) || {};
|
||||
|
||||
return cfg['.type'] === (ctx.routing_node_type || 'routing_node') &&
|
||||
cfg.enabled === '1' &&
|
||||
!isEmpty(cfg.node);
|
||||
};
|
||||
|
||||
function addRoutingNodeEdge(ctx, edges, target) {
|
||||
target = normalizeOutboundTargetValue(target, ctx?.tag_map);
|
||||
|
||||
if (is_routing_node_section(ctx, target))
|
||||
push(edges, target);
|
||||
}
|
||||
|
||||
function routingNodeEdges(ctx, section) {
|
||||
let cfg = ctx.uci.get_all(ctx.config, section) || {},
|
||||
edges = [];
|
||||
|
||||
if (!is_routing_node_section(ctx, section))
|
||||
return edges;
|
||||
|
||||
if (cfg.node === 'selector') {
|
||||
for (let node_id in toArray(cfg.selector_nodes))
|
||||
addRoutingNodeEdge(ctx, edges, node_id);
|
||||
addRoutingNodeEdge(ctx, edges, cfg.selector_default);
|
||||
} else if (cfg.node === 'urltest') {
|
||||
for (let node_id in toArray(cfg.urltest_nodes))
|
||||
addRoutingNodeEdge(ctx, edges, node_id);
|
||||
} else {
|
||||
addRoutingNodeEdge(ctx, edges, cfg.node);
|
||||
}
|
||||
|
||||
addRoutingNodeEdge(ctx, edges, cfg.outbound);
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
export function routing_node_has_path(ctx, start, target, seen) {
|
||||
start = normalizeOutboundTargetValue(start, ctx?.tag_map);
|
||||
target = normalizeOutboundTargetValue(target, ctx?.tag_map);
|
||||
|
||||
if (isEmpty(start) || isEmpty(target) || !is_routing_node_section(ctx, start))
|
||||
return false;
|
||||
if (start === target)
|
||||
return true;
|
||||
|
||||
seen = seen || {};
|
||||
if (seen[start])
|
||||
return false;
|
||||
seen[start] = true;
|
||||
|
||||
for (let edge in routingNodeEdges(ctx, start))
|
||||
if (routing_node_has_path(ctx, edge, target, seen))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export function normalize_outbound_target(target, tag_map) {
|
||||
return normalizeOutboundTargetValue(target, tag_map);
|
||||
};
|
||||
|
||||
export function resolve_outbound_target(ctx, target, owner, seen_path) {
|
||||
target = normalize_outbound_target(target, ctx?.tag_map);
|
||||
|
||||
if (isEmpty(target))
|
||||
return null;
|
||||
else if (is_builtin_outbound(ctx, target))
|
||||
return {
|
||||
type: 'builtin',
|
||||
outbound: target,
|
||||
target: target
|
||||
};
|
||||
else if (is_node_section(ctx, target))
|
||||
return {
|
||||
type: 'node',
|
||||
outbound: outboundTag(ctx, target),
|
||||
target: target,
|
||||
node_id: target
|
||||
};
|
||||
|
||||
if (!seen_path)
|
||||
seen_path = [];
|
||||
|
||||
if (~index(seen_path, target)) {
|
||||
report(ctx, 'error',
|
||||
sprintf('路由节点配置错误:检测到循环引用\n循环路径: %s',
|
||||
join(' -> ', [ ...seen_path, target ])),
|
||||
'进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,检查以下节点的"出站"配置,移除循环引用');
|
||||
return { fatal: true };
|
||||
}
|
||||
|
||||
const max_depth = ctx.max_depth || 20;
|
||||
if (length(seen_path) >= max_depth) {
|
||||
report(ctx, 'error',
|
||||
sprintf('路由节点配置错误:嵌套层级过深\n当前路径: %s\n最大允许层级: %d',
|
||||
join(' -> ', [ ...seen_path, target ]),
|
||||
max_depth),
|
||||
'简化路由节点的嵌套结构,避免过多的 Selector 嵌套');
|
||||
return { fatal: true };
|
||||
}
|
||||
|
||||
if (!is_routing_node_section(ctx, target)) {
|
||||
if (owner)
|
||||
report(ctx, 'warning',
|
||||
sprintf('%s 引用了已删除或无效的节点:%s,已在本次生成中跳过。', owner, join(' -> ', [ ...seen_path, target ])),
|
||||
'请进入 LuCI 界面检查对应的节点或路由节点引用');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const routing_node = ctx.uci.get(ctx.config, target, 'node');
|
||||
if (routing_node === 'urltest' || routing_node === 'selector') {
|
||||
return {
|
||||
type: routing_node,
|
||||
outbound: outboundTag(ctx, target),
|
||||
target: target,
|
||||
section_id: target
|
||||
};
|
||||
}
|
||||
|
||||
return resolve_outbound_target(ctx, routing_node, owner, [ ...seen_path, target ]);
|
||||
};
|
||||
|
||||
export function get_routing_target_outbound(ctx, target, owner) {
|
||||
const resolved = resolve_outbound_target(ctx, target, owner, []);
|
||||
|
||||
if (!resolved || resolved.fatal)
|
||||
return null;
|
||||
|
||||
return resolved.outbound;
|
||||
};
|
||||
|
||||
export function get_valid_selector_outbounds(ctx, selector_nodes, owner, owner_section) {
|
||||
let outbounds = [];
|
||||
|
||||
for (let node_id in selector_nodes) {
|
||||
if (owner_section && routing_node_has_path(ctx, node_id, owner_section, {})) {
|
||||
report(ctx, 'error',
|
||||
sprintf('路由节点配置错误:Selector %s 与 %s 形成循环引用。', owner, node_id),
|
||||
'进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,检查 Selector 节点列表、默认节点和上游出站配置');
|
||||
continue;
|
||||
}
|
||||
|
||||
const outbound = get_routing_target_outbound(
|
||||
ctx,
|
||||
node_id,
|
||||
sprintf('路由节点 %s 的 Selector 节点', owner)
|
||||
);
|
||||
|
||||
if (!isEmpty(outbound))
|
||||
push(outbounds, outbound);
|
||||
}
|
||||
|
||||
return outbounds;
|
||||
};
|
||||
|
||||
export function collect_routing_target_dependencies(ctx, client_config, target, routing_nodes, push_node_outbound, outbound_tag) {
|
||||
const resolved = resolve_outbound_target(ctx, target, null, []);
|
||||
|
||||
if (!resolved || resolved.fatal || resolved.type !== 'node' || ~index(routing_nodes, resolved.node_id))
|
||||
return false;
|
||||
|
||||
const node = ctx.uci.get_all(ctx.config, resolved.node_id) || {};
|
||||
push_node_outbound(client_config, node, outbound_tag(resolved.node_id));
|
||||
push(routing_nodes, resolved.node_id);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function get_outbound(ctx, cfg, fallback, owner) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
if (type(cfg) === 'array') {
|
||||
if ('any-out' in cfg)
|
||||
return 'any';
|
||||
|
||||
let outbounds = [];
|
||||
for (let i in cfg)
|
||||
push(outbounds, get_outbound(ctx, i, fallback, owner));
|
||||
return outbounds;
|
||||
}
|
||||
|
||||
switch (cfg) {
|
||||
case 'block-out':
|
||||
case 'direct-out':
|
||||
return cfg;
|
||||
default:
|
||||
const resolved = resolve_outbound_target(ctx, cfg, null, []);
|
||||
|
||||
if (!resolved || resolved.fatal) {
|
||||
report(ctx, 'warning',
|
||||
sprintf('%s引用了已删除或无效的节点:%s,已在本次生成中回退为 %s。', owner ? owner + ' ' : '出站', cfg, fallback || '空值'),
|
||||
'请进入 LuCI 界面检查路由、DNS、规则集中的出站引用');
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
return resolved.outbound;
|
||||
}
|
||||
};
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 订阅 parser helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function is_empty(ctx, value) {
|
||||
if (type(ctx?.isEmpty) === 'function')
|
||||
return ctx.isEmpty(value);
|
||||
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function validate(ctx, kind, value) {
|
||||
return type(ctx?.validation) === 'function' ? ctx.validation(kind, value) : false;
|
||||
}
|
||||
|
||||
function log(ctx, ...args) {
|
||||
if (type(ctx?.log) === 'function')
|
||||
ctx.log(...args);
|
||||
}
|
||||
|
||||
function singFeatures(ctx) {
|
||||
return ctx?.sing_features || {};
|
||||
}
|
||||
|
||||
function applyTlsCertPinPolicy(ctx, config, label, pin) {
|
||||
if (type(ctx?.apply_tls_cert_pin_policy) === 'function')
|
||||
ctx.apply_tls_cert_pin_policy(config, label, pin);
|
||||
}
|
||||
|
||||
function parse_surge_proxy(ctx, line) {
|
||||
let config;
|
||||
|
||||
const eq = index(line, '=');
|
||||
if (eq < 0)
|
||||
return null;
|
||||
|
||||
const label = trim(substr(line, 0, eq));
|
||||
const rhs = trim(substr(line, eq + 1));
|
||||
if (is_empty(ctx, label) || is_empty(ctx, rhs))
|
||||
return null;
|
||||
|
||||
const tokens = [];
|
||||
let cur = '';
|
||||
let in_quote = false;
|
||||
for (let i = 0; i < length(rhs); i++) {
|
||||
const ch = substr(rhs, i, 1);
|
||||
if (ch == '"')
|
||||
in_quote = !in_quote;
|
||||
else if (ch == ',' && !in_quote) {
|
||||
push(tokens, trim(cur));
|
||||
cur = '';
|
||||
} else
|
||||
cur += ch;
|
||||
}
|
||||
if (length(trim(cur)))
|
||||
push(tokens, trim(cur));
|
||||
|
||||
if (length(tokens) < 3)
|
||||
return null;
|
||||
|
||||
const surge_type = tokens[0];
|
||||
const address = tokens[1];
|
||||
const port = tokens[2];
|
||||
const opts = {};
|
||||
let last_key = null;
|
||||
for (let i = 3; i < length(tokens); i++) {
|
||||
const t = tokens[i];
|
||||
const k = index(t, '=');
|
||||
if (k < 0) {
|
||||
if (last_key == 'port-hopping')
|
||||
opts[last_key] = opts[last_key] + ',' + t;
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = lc(trim(substr(t, 0, k)));
|
||||
let val = trim(substr(t, k + 1));
|
||||
if (length(val) >= 2 && substr(val, 0, 1) == '"' && substr(val, length(val) - 1, 1) == '"')
|
||||
val = substr(val, 1, length(val) - 2);
|
||||
opts[key] = val;
|
||||
last_key = key;
|
||||
}
|
||||
|
||||
function bval(v) { return (v == 'true') ? '1' : '0'; }
|
||||
|
||||
switch (surge_type) {
|
||||
case 'trojan':
|
||||
config = {
|
||||
label: label,
|
||||
type: 'trojan',
|
||||
address: address,
|
||||
port: port,
|
||||
password: opts.password,
|
||||
tls: '1',
|
||||
tls_sni: opts.sni,
|
||||
tls_insecure: opts['skip-cert-verify'] ? bval(opts['skip-cert-verify']) : '0',
|
||||
tls_alpn: opts.alpn ? split(opts.alpn, ',') : null
|
||||
};
|
||||
if (opts.ws == 'true') {
|
||||
config.transport = 'ws';
|
||||
config.ws_path = opts['ws-path'];
|
||||
if (opts['ws-headers']) {
|
||||
const hm = match(opts['ws-headers'], /[Hh]ost\s*:\s*"?([^",]+)/);
|
||||
if (hm)
|
||||
config.ws_host = hm[1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ss':
|
||||
config = {
|
||||
label: label,
|
||||
type: 'shadowsocks',
|
||||
address: address,
|
||||
port: port,
|
||||
shadowsocks_encrypt_method: opts['encrypt-method'],
|
||||
password: opts.password
|
||||
};
|
||||
if (opts['udp-over-tcp'] == 'true') {
|
||||
config.udp_over_tcp = '1';
|
||||
config.udp_over_tcp_version = '2';
|
||||
}
|
||||
if (opts['shadow-tls-password'] && opts['shadow-tls-sni']) {
|
||||
config.shadowtls_enabled = '1';
|
||||
config.shadowtls_address = address;
|
||||
config.shadowtls_port = port;
|
||||
config.shadowtls_password = opts['shadow-tls-password'];
|
||||
config.shadowtls_sni = opts['shadow-tls-sni'];
|
||||
config.shadowtls_version = opts['shadow-tls-version'] || '3';
|
||||
}
|
||||
break;
|
||||
case 'hysteria2':
|
||||
case 'hy2':
|
||||
if (!singFeatures(ctx).with_quic) {
|
||||
log(ctx, sprintf('Skipping unsupported %s node: %s.', surge_type, label));
|
||||
log(ctx, sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
return null;
|
||||
}
|
||||
config = {
|
||||
label: label,
|
||||
type: 'hysteria2',
|
||||
address: address,
|
||||
port: port,
|
||||
password: opts.password,
|
||||
hysteria_obfs_type: opts.obfs,
|
||||
hysteria_obfs_password: opts['salamander-password'] || opts['obfs-password'],
|
||||
tls: '1',
|
||||
tls_sni: opts.sni,
|
||||
tls_insecure: opts['skip-cert-verify'] ? bval(opts['skip-cert-verify']) : '0'
|
||||
};
|
||||
applyTlsCertPinPolicy(ctx, config, label, opts['server-cert-fingerprint-sha256']);
|
||||
if (opts['port-hopping'])
|
||||
config.hysteria_hopping_port = map(split(opts['port-hopping'], ','),
|
||||
(seg) => replace(trim(seg), '-', ':'));
|
||||
if (opts.alpn)
|
||||
config.tls_alpn = split(opts.alpn, ',');
|
||||
break;
|
||||
case 'anytls':
|
||||
config = {
|
||||
label: label,
|
||||
type: 'anytls',
|
||||
address: address,
|
||||
port: port,
|
||||
password: opts.password,
|
||||
tls: '1',
|
||||
tls_sni: opts.sni,
|
||||
tls_insecure: opts['skip-cert-verify'] ? bval(opts['skip-cert-verify']) : '0'
|
||||
};
|
||||
break;
|
||||
default:
|
||||
log(ctx, sprintf('Skipping unsupported Surge proxy type: %s (%s).', surge_type, label));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_empty(ctx, config)) {
|
||||
if (config.address)
|
||||
config.address = replace(config.address, /\[|\]/g, '');
|
||||
|
||||
if (!validate(ctx, 'host', config.address) || !validate(ctx, 'port', config.port)) {
|
||||
log(ctx, sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function parse_surge_subscription(ctx, body) {
|
||||
const lines = split(body, '\n');
|
||||
const nodes = [];
|
||||
let in_proxies = false;
|
||||
|
||||
for (let i = 0; i < length(lines); i++) {
|
||||
const ln = trim(lines[i]);
|
||||
|
||||
if (match(lines[i], /^[ \t]*proxies\s*:/)) {
|
||||
in_proxies = true;
|
||||
continue;
|
||||
}
|
||||
if (in_proxies && match(lines[i], /^[A-Za-z0-9_-]+\s*:/) && !match(lines[i], /^\s/))
|
||||
in_proxies = false;
|
||||
if (!in_proxies)
|
||||
continue;
|
||||
|
||||
if (is_empty(ctx, ln) || substr(ln, 0, 1) == '#')
|
||||
continue;
|
||||
|
||||
if (index(ln, '=') < 0)
|
||||
continue;
|
||||
|
||||
const cfg = parse_surge_proxy(ctx, ln);
|
||||
if (cfg)
|
||||
push(nodes, cfg);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
|
||||
set -eu
|
||||
|
||||
NAME="homeproxy"
|
||||
RUN_DIR="/var/run/$NAME"
|
||||
REQUEST_FILE="$RUN_DIR/provider-update-id"
|
||||
LOCK_FILE="$RUN_DIR/provider-update-v2.lock"
|
||||
LOG_FILE="$RUN_DIR/homeproxy.log"
|
||||
PROVIDER_ID="${1:-}"
|
||||
|
||||
log() {
|
||||
printf '%s [SUBSCRIBE] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
case "$PROVIDER_ID" in
|
||||
*[!0-9a-f]*|'') exit 1 ;;
|
||||
esac
|
||||
[ "${#PROVIDER_ID}" -eq 32 ] || exit 1
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
if ! lock -n "$LOCK_FILE" 2>/dev/null; then
|
||||
log "Another provider update is running; waiting for it to finish..."
|
||||
lock "$LOCK_FILE"
|
||||
fi
|
||||
|
||||
umask 077
|
||||
printf '%s\n' "$PROVIDER_ID" > "$REQUEST_FILE"
|
||||
cleanup() {
|
||||
rm -f "$REQUEST_FILE"
|
||||
lock -u "$LOCK_FILE" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# Give the Clash API response time to reach the panel before HomeProxy restarts.
|
||||
log "Updating provider $PROVIDER_ID from Clash API..."
|
||||
sleep 1
|
||||
if /etc/homeproxy/scripts/update_subscriptions.uc; then
|
||||
log "Provider $PROVIDER_ID update completed."
|
||||
else
|
||||
log "Provider $PROVIDER_ID update failed."
|
||||
exit 4
|
||||
fi
|
||||
@@ -21,64 +21,65 @@ to_upper() {
|
||||
}
|
||||
|
||||
check_list_update() {
|
||||
local listtype="$1"
|
||||
local listrepo="$2"
|
||||
local listref="$3"
|
||||
local listname="$4"
|
||||
local lock="$RUN_DIR/update_resources-$listtype.lock"
|
||||
local github_token="$(uci -q get homeproxy.config.github_token)"
|
||||
local wget="wget --timeout=10 -q"
|
||||
local LIST_FILE="$1"
|
||||
local REPO_NAME="$2"
|
||||
local REPO_BRANCH="$3"
|
||||
local REPO_FILE="$4"
|
||||
local LOCK_FILE="$RUN_DIR/update_resources-$LIST_FILE.lock"
|
||||
local GITHUB_TOKEN="$(uci -q get homeproxy.config.github_token)"
|
||||
|
||||
exec 200>"$lock"
|
||||
if ! flock -n 200 &> "/dev/null"; then
|
||||
log "[$(to_upper "$listtype")] A task is already running."
|
||||
if ! lock -n "$LOCK_FILE" 2>"/dev/null"; then
|
||||
log "[$(to_upper "$LIST_FILE")] A task is already running."
|
||||
return 2
|
||||
fi
|
||||
|
||||
[ -z "$github_token" ] || github_token="--header=Authorization: Bearer $github_token"
|
||||
local list_info="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
|
||||
local list_sha="$(echo -e "$list_info" | jsonfilter -qe "@[0].sha")"
|
||||
local list_ver="$(echo -e "$list_info" | jsonfilter -qe "@[0].commit.message" | grep -Eo "[0-9-]+" | tr -d '-')"
|
||||
if [ -z "$list_sha" ] || [ -z "$list_ver" ]; then
|
||||
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
|
||||
local NEW_VER=$(curl -sL ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} "https://api.github.com/repos/$REPO_NAME/releases/latest" | jsonfilter -e "@.tag_name")
|
||||
if [ -z "$NEW_VER" ]; then
|
||||
log "[$(to_upper "$LIST_FILE")] Failed to get the latest version, please retry later."
|
||||
|
||||
return 1
|
||||
fi
|
||||
|
||||
local local_list_ver="$(cat "$RESOURCES_DIR/$listtype.ver" 2>"/dev/null" || echo "NOT FOUND")"
|
||||
if [ "$local_list_ver" = "$list_ver" ]; then
|
||||
log "[$(to_upper "$listtype")] Current version: $list_ver."
|
||||
log "[$(to_upper "$listtype")] You're already at the latest version."
|
||||
local OLD_VER=$(cat "$RESOURCES_DIR/$LIST_FILE.ver" 2>/dev/null || echo "NOT FOUND")
|
||||
if [ "$OLD_VER" = "$NEW_VER" ]; then
|
||||
log "[$(to_upper "$LIST_FILE")] Current version: $NEW_VER."
|
||||
log "[$(to_upper "$LIST_FILE")] You're already at the latest version."
|
||||
|
||||
return 3
|
||||
else
|
||||
log "[$(to_upper "$listtype")] Local version: $local_list_ver, latest version: $list_ver."
|
||||
log "[$(to_upper "$LIST_FILE")] Local version: $OLD_VER, latest version: $NEW_VER."
|
||||
fi
|
||||
|
||||
if ! $wget "https://fastly.jsdelivr.net/gh/$listrepo@$list_sha/$listname" -O "$RUN_DIR/$listname" || [ ! -s "$RUN_DIR/$listname" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Update failed."
|
||||
if ! curl -sL -o "$RUN_DIR/$REPO_FILE" "https://cdn.jsdelivr.net/gh/$REPO_NAME@$REPO_BRANCH/$REPO_FILE" || [ ! -s "$RUN_DIR/$REPO_FILE" ]; then
|
||||
rm -f "$RUN_DIR/$REPO_FILE"
|
||||
log "[$(to_upper "$LIST_FILE")] Update failed."
|
||||
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"
|
||||
echo -e "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
|
||||
log "[$(to_upper "$listtype")] Successfully updated."
|
||||
mv -f "$RUN_DIR/$REPO_FILE" "$RESOURCES_DIR/$LIST_FILE.${REPO_FILE##*.}"
|
||||
echo -e "$NEW_VER" > "$RESOURCES_DIR/$LIST_FILE.ver"
|
||||
log "[$(to_upper "$LIST_FILE")] Successfully updated."
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
"china_ip4")
|
||||
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
|
||||
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "cncidr.txt" && \
|
||||
sed -i "/IP-CIDR6,/d; s/IP-CIDR,//g" "$RESOURCES_DIR/china_ip4.txt"
|
||||
;;
|
||||
"china_ip6")
|
||||
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
|
||||
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "cncidr.txt" && \
|
||||
sed -i "/IP-CIDR,/d; s/IP-CIDR6,//g" "$RESOURCES_DIR/china_ip6.txt"
|
||||
;;
|
||||
"gfw_list")
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
|
||||
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "gfw.txt" && \
|
||||
sed -i "s/^\.//g" "$RESOURCES_DIR/gfw_list.txt"
|
||||
;;
|
||||
"china_list")
|
||||
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"
|
||||
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "direct.txt" && \
|
||||
sed -i "s/^\.//g" "$RESOURCES_DIR/china_list.txt"
|
||||
;;
|
||||
*)
|
||||
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
|
||||
CACHE_PATH="$(uci -q get homeproxy.cache.path)"
|
||||
[ -n "$CACHE_PATH" ] || CACHE_PATH="/etc/homeproxy/cache.db"
|
||||
|
||||
rm -f "$CACHE_PATH"
|
||||
/etc/init.d/homeproxy restart
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,35 +27,88 @@ log() {
|
||||
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
|
||||
}
|
||||
|
||||
delete_ip_rules() {
|
||||
local mark="$1"
|
||||
local table="$2"
|
||||
|
||||
while ip rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
|
||||
:
|
||||
done
|
||||
init_logs() {
|
||||
mkdir -p "$RUN_DIR"
|
||||
mkdir -p "$HP_DIR/run/ui"
|
||||
touch "$LOG_PATH" "$RUN_DIR/sing-box-c.log" "$RUN_DIR/sing-box-s.log"
|
||||
chown -R sing-box:sing-box "$RUN_DIR" 2>"/dev/null"
|
||||
chown -R sing-box:sing-box "$HP_DIR/run" 2>"/dev/null"
|
||||
}
|
||||
|
||||
delete_ip6_rules() {
|
||||
local mark="$1"
|
||||
local table="$2"
|
||||
valid_update_cron() {
|
||||
echo "$1" | grep -Eq '^([0-9]|[0-5][0-9]) ([0-9]|[01][0-9]|2[0-3]) \* \* ([0-7]|\*)$'
|
||||
}
|
||||
|
||||
while ip -6 rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
|
||||
:
|
||||
done
|
||||
has_ruleset_cron() {
|
||||
printf '%s\n' "$ruleset_cron_list" | grep -Fxq -- "$1"
|
||||
}
|
||||
|
||||
add_ruleset_cron() {
|
||||
local section="$1"
|
||||
local enabled auto_update type update_time
|
||||
|
||||
config_get_bool enabled "$section" "enabled" "0"
|
||||
config_get_bool auto_update "$section" "auto_update" "1"
|
||||
config_get type "$section" "type" "local"
|
||||
config_get update_time "$section" "update_interval" ""
|
||||
|
||||
[ "$enabled" = "1" ] || return
|
||||
[ "$auto_update" = "1" ] || return
|
||||
[ "$type" = "remote" ] || return
|
||||
[ -n "$update_time" ] || return
|
||||
valid_update_cron "$update_time" || return
|
||||
has_ruleset_cron "$update_time" && return
|
||||
|
||||
echo -e "$update_time sh $HP_DIR/scripts/update_rulesets.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
|
||||
ruleset_cron_list="${ruleset_cron_list}
|
||||
${update_time}"
|
||||
cron_reload="1"
|
||||
}
|
||||
|
||||
setup_auto_update_cron() {
|
||||
local cron_reload="0"
|
||||
mkdir -p "/etc/crontabs"
|
||||
touch "/etc/crontabs/root"
|
||||
grep -q "#${CONF}_autosetup" "/etc/crontabs/root" 2>"/dev/null" && cron_reload="1"
|
||||
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
|
||||
|
||||
local auto_update auto_update_time
|
||||
config_get_bool auto_update "subscription" "auto_update" "0"
|
||||
if [ "$auto_update" = "1" ]; then
|
||||
config_get auto_update_time "subscription" "auto_update_time" "0 0 * * *"
|
||||
if valid_update_cron "$auto_update_time"; then
|
||||
echo -e "$auto_update_time sh $HP_DIR/scripts/update_crond.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
|
||||
cron_reload="1"
|
||||
fi
|
||||
fi
|
||||
|
||||
local ruleset_cron_list=""
|
||||
config_foreach add_ruleset_cron "ruleset"
|
||||
[ "$cron_reload" = "1" ] && /etc/init.d/cron restart
|
||||
}
|
||||
|
||||
start_service() {
|
||||
init_logs
|
||||
|
||||
config_load "$CONF"
|
||||
setup_auto_update_cron
|
||||
|
||||
local routing_mode proxy_mode
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
|
||||
|
||||
local outbound_node default_outbound
|
||||
config_get outbound_node "config" "main_node" "nil"
|
||||
config_get default_outbound "routing" "default_outbound" "nil"
|
||||
[ "$outbound_node" != "nil" ] || outbound_node="$default_outbound"
|
||||
local cache_path
|
||||
config_get cache_path "cache" "path" "$HP_DIR/cache.db"
|
||||
mkdir -p "$(dirname "$cache_path")"
|
||||
[ -e "$cache_path" ] || touch "$cache_path"
|
||||
chown sing-box:sing-box "$cache_path" 2>"/dev/null"
|
||||
|
||||
local outbound_node
|
||||
if [ "$routing_mode" != "custom" ]; then
|
||||
config_get outbound_node "config" "main_node" "nil"
|
||||
else
|
||||
config_get outbound_node "routing" "default_outbound" "nil"
|
||||
fi
|
||||
|
||||
local server_enabled
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
@@ -64,8 +117,6 @@ start_service() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
if [ "$outbound_node" != "nil" ]; then
|
||||
# Generate/Validate client config
|
||||
ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"
|
||||
@@ -78,16 +129,6 @@ start_service() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Auto update
|
||||
local auto_update auto_update_time
|
||||
config_get_bool auto_update "subscription" "auto_update" "0"
|
||||
if [ "$auto_update" = "1" ]; then
|
||||
config_get auto_update_time "subscription" "auto_update_time" "2"
|
||||
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
|
||||
echo -e "0 $auto_update_time * * * $HP_DIR/scripts/update_crond.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
|
||||
/etc/init.d/cron restart
|
||||
fi
|
||||
|
||||
# DNSMasq rules
|
||||
local ipv6_support dns_port
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
@@ -120,31 +161,23 @@ start_service() {
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
|
||||
"$HP_DIR/resources/proxy_list.txt" > "$DNSMASQ_DIR/proxy_list.conf"
|
||||
fi
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
|
||||
|
||||
# Setup routing table
|
||||
local table_mark
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
case "$proxy_mode" in
|
||||
"redirect_tproxy")
|
||||
local outbound_udp_node main_node_for_routing default_outbound_for_routing use_default_outbound_routing
|
||||
local outbound_udp_node
|
||||
config_get outbound_udp_node "config" "main_udp_node" "nil"
|
||||
config_get main_node_for_routing "config" "main_node" "nil"
|
||||
config_get default_outbound_for_routing "routing" "default_outbound" "nil"
|
||||
[ "$routing_mode" != "custom" ] && [ "$main_node_for_routing" = "nil" ] && [ "$default_outbound_for_routing" != "nil" ] \
|
||||
&& use_default_outbound_routing="1" || use_default_outbound_routing="0"
|
||||
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ] || [ "$use_default_outbound_routing" = "1" ]; then
|
||||
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
|
||||
local tproxy_mark
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
|
||||
delete_ip_rules "$tproxy_mark" "$table_mark"
|
||||
ip rule add fwmark "$tproxy_mark" table "$table_mark"
|
||||
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
|
||||
ip route add local 0.0.0.0/0 dev lo table "$table_mark"
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
delete_ip6_rules "$tproxy_mark" "$table_mark"
|
||||
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark"
|
||||
ip -6 route replace local ::/0 dev lo table "$table_mark"
|
||||
ip -6 route add local ::/0 dev lo table "$table_mark"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
@@ -153,16 +186,14 @@ start_service() {
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
|
||||
ip link show "$tun_name" >"/dev/null" 2>&1 || ip tuntap add mode tun user root name "$tun_name"
|
||||
ip tuntap add mode tun user root name "$tun_name"
|
||||
sleep 1s
|
||||
ip link set "$tun_name" up
|
||||
|
||||
ip route replace default dev "$tun_name" table "$table_mark"
|
||||
delete_ip_rules "$tun_mark" "$table_mark"
|
||||
ip rule add fwmark "$tun_mark" lookup "$table_mark"
|
||||
|
||||
ip -6 route replace default dev "$tun_name" table "$table_mark"
|
||||
delete_ip6_rules "$tun_mark" "$table_mark"
|
||||
ip -6 rule add fwmark "$tun_mark" lookup "$table_mark"
|
||||
;;
|
||||
esac
|
||||
@@ -180,7 +211,8 @@ start_service() {
|
||||
procd_add_jail "sing-box-c" log procfs
|
||||
procd_add_jail_mount "$RUN_DIR/sing-box-c.json"
|
||||
procd_add_jail_mount_rw "$RUN_DIR/sing-box-c.log"
|
||||
[ "$routing_mode" != "bypass_mainland_china" ] || procd_add_jail_mount_rw "$RUN_DIR/cache.db"
|
||||
procd_add_jail_mount_rw "$cache_path"
|
||||
procd_add_jail_mount_rw "$HP_DIR/run/"
|
||||
procd_add_jail_mount "$HP_DIR/certs/"
|
||||
procd_add_jail_mount "/etc/ssl/"
|
||||
procd_add_jail_mount "/etc/localtime"
|
||||
@@ -197,17 +229,6 @@ start_service() {
|
||||
procd_set_param respawn
|
||||
|
||||
procd_close_instance
|
||||
|
||||
# Clash API display proxy for dashboards.
|
||||
local clash_api_enabled
|
||||
config_get_bool clash_api_enabled "config" "clash_api_enabled" "0"
|
||||
if [ "$clash_api_enabled" = "1" ]; then
|
||||
procd_open_instance "clash-api-proxy"
|
||||
procd_set_param command "/usr/bin/ucode" "$HP_DIR/scripts/clash_api_proxy.uc"
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
@@ -261,19 +282,15 @@ start_service() {
|
||||
procd_close_instance
|
||||
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china")
|
||||
# Prepare cache db
|
||||
[ -e "$RUN_DIR/cache.db" ] || touch "$RUN_DIR/cache.db"
|
||||
;;
|
||||
"custom")
|
||||
# Prepare ruleset directory
|
||||
[ -d "$HP_DIR/ruleset" ] || mkdir -p "$HP_DIR/ruleset"
|
||||
;;
|
||||
esac
|
||||
|
||||
[ "$outbound_node" = "nil" ] || echo > "$RUN_DIR/sing-box-c.log"
|
||||
[ "$outbound_node" = "nil" ] || : > "$RUN_DIR/sing-box-c.log"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
echo > "$RUN_DIR/sing-box-s.log"
|
||||
: > "$RUN_DIR/sing-box-s.log"
|
||||
mkdir -p "$HP_DIR/certs"
|
||||
fi
|
||||
|
||||
@@ -284,6 +301,7 @@ start_service() {
|
||||
ucode "$HP_DIR/scripts/firewall_pre.uc"
|
||||
[ "$outbound_node" = "nil" ] || utpl -S "$HP_DIR/scripts/firewall_post.ut" > "$RUN_DIR/fw4_post.nft"
|
||||
fw4 reload >"/dev/null" 2>&1
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
|
||||
|
||||
log "sing-box $(sing-box version -n) started."
|
||||
}
|
||||
@@ -302,17 +320,17 @@ stop_service() {
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
# Tproxy
|
||||
delete_ip_rules "$tproxy_mark" "$table_mark"
|
||||
ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
|
||||
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
delete_ip6_rules "$tproxy_mark" "$table_mark"
|
||||
ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
|
||||
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
|
||||
# TUN
|
||||
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
delete_ip_rules "$tun_mark" "$table_mark"
|
||||
ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
|
||||
|
||||
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
delete_ip6_rules "$tun_mark" "$table_mark"
|
||||
ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
|
||||
|
||||
# Nftables rules
|
||||
for i in "homeproxy_dstnat_redir" "homeproxy_output_redir" \
|
||||
@@ -343,8 +361,8 @@ stop_service() {
|
||||
rm -rf "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" "$DNSMASQ_DIR"
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
|
||||
|
||||
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
|
||||
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log"
|
||||
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-s.json"
|
||||
init_logs
|
||||
|
||||
log "Service stopped."
|
||||
}
|
||||
|
||||
Regular → Executable
+30
@@ -3,6 +3,36 @@
|
||||
[ -f "/www/luci-static/resources/icons/loading.gif" ] && \
|
||||
sed -i "s,/loading.svg,/loading.gif,g" "/www/luci-static/resources/view/homeproxy/status.js"
|
||||
|
||||
uci -q set luci.main.lang='zh_cn'
|
||||
uci -q commit luci
|
||||
|
||||
api_secret="$(uci -q get homeproxy.clash_api.secret)"
|
||||
if [ -z "$api_secret" ] || [ "$api_secret" = "homeproxy" ]; then
|
||||
api_secret="$(tr -dc 'A-Za-z0-9' < /dev/urandom 2>"/dev/null" | head -c 16)"
|
||||
[ -n "$api_secret" ] || api_secret="$(awk 'BEGIN{srand(); printf "%06d", int(rand() * 1000000)}')"
|
||||
uci -q set homeproxy.clash_api.secret="$api_secret"
|
||||
uci -q commit homeproxy
|
||||
fi
|
||||
|
||||
legacy_panel_url="https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip"
|
||||
legacy_panel_proxy_url="https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip"
|
||||
old_panel_url="https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
|
||||
old_panel_proxy_url="https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
|
||||
default_panel_url="https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
|
||||
panel_url="$(uci -q get homeproxy.clash_api.external_ui_download_url)"
|
||||
if [ -z "$panel_url" ] || [ "$panel_url" = "$legacy_panel_url" ] || [ "$panel_url" = "$legacy_panel_proxy_url" ] || [ "$panel_url" = "$old_panel_proxy_url" ]; then
|
||||
uci -q set homeproxy.clash_api.external_ui_download_url="$default_panel_url"
|
||||
uci -q commit homeproxy
|
||||
fi
|
||||
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>"/dev/null"
|
||||
|
||||
mkdir -p /var/run/homeproxy
|
||||
touch /var/run/homeproxy/homeproxy.log \
|
||||
/var/run/homeproxy/sing-box-c.log \
|
||||
/var/run/homeproxy/sing-box-s.log
|
||||
chown -R sing-box:sing-box /var/run/homeproxy 2>"/dev/null"
|
||||
|
||||
uci -q batch <<-EOF >"/dev/null"
|
||||
delete firewall.homeproxy_pre
|
||||
|
||||
|
||||
Regular → Executable
File mode changed.
@@ -34,14 +34,6 @@
|
||||
"path": "homeproxy/server"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/backup": {
|
||||
"title": "Backup / Restore",
|
||||
"order": 25,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/backup"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/status": {
|
||||
"title": "Service Status",
|
||||
"order": 30,
|
||||
|
||||
@@ -3,58 +3,25 @@
|
||||
"description": "Grant access to homeproxy configuration",
|
||||
"read": {
|
||||
"file": {
|
||||
"/etc/homeproxy/scripts/update_subscriptions.uc": [ "exec" ],
|
||||
"/var/run/homeproxy/homeproxy.log": [ "read" ],
|
||||
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
|
||||
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
|
||||
},
|
||||
"ubus": {
|
||||
"rc": [ "*" ],
|
||||
"service": [ "list" ],
|
||||
"luci.homeproxy": [
|
||||
"acllist_read",
|
||||
"connection_check",
|
||||
"resources_get_version",
|
||||
"singbox_get_features"
|
||||
],
|
||||
"luci.homeproxy_node_tools": [
|
||||
"config_diagnostics",
|
||||
"node_references",
|
||||
"preview_node_filter",
|
||||
"update_subscriptions_status",
|
||||
"validate_regex"
|
||||
]
|
||||
"luci.homeproxy": [ "*" ]
|
||||
},
|
||||
"uci": [ "homeproxy" ]
|
||||
},
|
||||
"write": {
|
||||
"file": {
|
||||
"/tmp/homeproxy_certificate.tmp": [ "write" ],
|
||||
"/tmp/homeproxy-backup-*.tar.gz": [ "read" ],
|
||||
"/tmp/homeproxy-restore-*.tar.gz": [ "write" ]
|
||||
"/tmp/homeproxy_panel.tmp": [ "write" ]
|
||||
},
|
||||
"ubus": {
|
||||
"luci.homeproxy": [
|
||||
"acllist_read",
|
||||
"acllist_write",
|
||||
"certificate_write",
|
||||
"log_clean",
|
||||
"resources_update",
|
||||
"singbox_generator"
|
||||
],
|
||||
"luci.homeproxy_backup": [
|
||||
"backup_cleanup",
|
||||
"backup_create",
|
||||
"backup_get_upload_path",
|
||||
"backup_restore",
|
||||
"backup_validate"
|
||||
],
|
||||
"luci.homeproxy_node_tools": [
|
||||
"remove_subscription_nodes",
|
||||
"update_subscriptions"
|
||||
],
|
||||
"luci.homeproxy_tcping": [
|
||||
"node_tcping",
|
||||
"nodes_tcping"
|
||||
]
|
||||
"rc": [ "*" ]
|
||||
},
|
||||
"uci": [ "homeproxy" ]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,553 +0,0 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 备份和恢复 RPC。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { lstat, popen, readfile, stat, writefile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import {
|
||||
local_ipv4_addresses,
|
||||
normalize_local_controller_option
|
||||
} from '/etc/homeproxy/scripts/clash_api.uc';
|
||||
|
||||
function shellquote(s) {
|
||||
return `'${replace(s, "'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
const HP_DIR = '/etc/homeproxy';
|
||||
const RUN_DIR = '/var/run/homeproxy';
|
||||
|
||||
function commandOutput(command) {
|
||||
const fd = popen(`${command} 2>&1`);
|
||||
let out = '';
|
||||
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line'))
|
||||
out += line;
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function generateTempSuffix() {
|
||||
let suffix = trim(commandOutput("hexdump -n 8 -e '1/1 \"%02x\"' /dev/urandom 2>/dev/null"));
|
||||
|
||||
if (!match(suffix, /^[a-f0-9]{16}$/))
|
||||
suffix = replace(trim(commandOutput('date +%s 2>/dev/null')), /[^a-f0-9]/g, '');
|
||||
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function createWorkDir() {
|
||||
const work_dir = '/tmp/homeproxy-work-' + generateTempSuffix();
|
||||
system(`mkdir -p ${shellquote(work_dir)} && chmod 700 ${shellquote(work_dir)}`);
|
||||
return work_dir;
|
||||
}
|
||||
|
||||
let BACKUP_ARCHIVE = '/tmp/homeproxy-backup-' + generateTempSuffix() + '.tar.gz';
|
||||
|
||||
function isEmpty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function normalizeRestoredLocalControllers() {
|
||||
let local_addresses = local_ipv4_addresses(commandOutput);
|
||||
let fallback_host = local_addresses[0];
|
||||
|
||||
if (isEmpty(fallback_host))
|
||||
return {};
|
||||
|
||||
const hpuci = cursor();
|
||||
let adjustments = {};
|
||||
|
||||
hpuci.load('homeproxy');
|
||||
|
||||
for (let item in [
|
||||
[ 'clash_api_proxy_external_controller', 9091, false ]
|
||||
]) {
|
||||
let next = normalize_local_controller_option(hpuci, 'homeproxy', 'config',
|
||||
local_addresses, fallback_host, item[0], item[1], item[2]);
|
||||
|
||||
if (next)
|
||||
adjustments[item[0]] = next;
|
||||
}
|
||||
|
||||
if (length(keys(adjustments)))
|
||||
hpuci.commit('homeproxy');
|
||||
|
||||
return adjustments;
|
||||
}
|
||||
|
||||
function restoredServiceExpectations() {
|
||||
const hpuci = cursor();
|
||||
hpuci.load('homeproxy');
|
||||
|
||||
const main_node = hpuci.get('homeproxy', 'config', 'main_node') || 'nil',
|
||||
default_outbound = hpuci.get('homeproxy', 'routing', 'default_outbound') || 'nil',
|
||||
outbound_node = isEmpty(main_node) ? default_outbound : main_node,
|
||||
server_enabled = hpuci.get('homeproxy', 'server', 'enabled') || '0';
|
||||
|
||||
return {
|
||||
client: !isEmpty(outbound_node),
|
||||
server: server_enabled === '1'
|
||||
};
|
||||
}
|
||||
|
||||
function checkSingBoxRuntimeConfig(path, label) {
|
||||
const config = stat(path);
|
||||
|
||||
if (!config || config.type !== 'file' || config.size <= 0)
|
||||
return `${label}运行配置未生成`;
|
||||
|
||||
if (system(`/usr/bin/sing-box check --config ${shellquote(path)} >/dev/null 2>&1`) !== 0)
|
||||
return `${label}运行配置校验失败`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRestoredService() {
|
||||
const expected = restoredServiceExpectations();
|
||||
|
||||
if (expected.client) {
|
||||
let error = checkSingBoxRuntimeConfig(`${RUN_DIR}/sing-box-c.json`, '客户端');
|
||||
if (error)
|
||||
return { result: false, error };
|
||||
}
|
||||
|
||||
if (expected.server) {
|
||||
let error = checkSingBoxRuntimeConfig(`${RUN_DIR}/sing-box-s.json`, '服务端');
|
||||
if (error)
|
||||
return { result: false, error };
|
||||
}
|
||||
|
||||
if (expected.client || expected.server) {
|
||||
const status = commandOutput('/etc/init.d/homeproxy status');
|
||||
|
||||
if (match(status, /active with no instances/))
|
||||
return { result: false, error: 'HomeProxy 服务未启动任何实例' };
|
||||
|
||||
if (system('/etc/init.d/homeproxy status >/dev/null 2>&1') !== 0)
|
||||
return { result: false, error: 'HomeProxy 服务状态异常' };
|
||||
}
|
||||
|
||||
return { result: true };
|
||||
}
|
||||
|
||||
function archiveMemberPath(line) {
|
||||
line = trim(line || '');
|
||||
if (!line)
|
||||
return null;
|
||||
|
||||
let matchPath = match(line, /^[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+(.+)$/);
|
||||
let path = matchPath ? matchPath[1] : line;
|
||||
|
||||
path = replace(path, /^\.\//, '');
|
||||
path = replace(path, /^\/+/, '');
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function allowedBackupPath(path) {
|
||||
if (!path || match(path, /(^|\/)\.\.(\/|$)/) || match(path, /^etc\/homeproxy\/certs\/.*\//))
|
||||
return false;
|
||||
|
||||
return (
|
||||
path === 'etc/config/homeproxy' ||
|
||||
path === 'etc/homeproxy/resources/direct_list.txt' ||
|
||||
path === 'etc/homeproxy/resources/proxy_list.txt' ||
|
||||
match(path, /^etc\/homeproxy\/certs\/[^\/]+$/)
|
||||
);
|
||||
}
|
||||
|
||||
function validateBackupArchive(path) {
|
||||
const filestat = lstat(path);
|
||||
|
||||
if (!filestat || filestat.type !== 'file' || filestat.size <= 0)
|
||||
return { valid: false, error: '备份文件不存在或内容为空' };
|
||||
|
||||
const fd = popen(`/bin/tar -tvzf ${shellquote(path)} 2>&1`);
|
||||
let files = [], errors = [];
|
||||
|
||||
if (!fd)
|
||||
return { valid: false, error: '无法运行 tar 检查备份文件' };
|
||||
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
let path = archiveMemberPath(line);
|
||||
if (!path)
|
||||
continue;
|
||||
|
||||
let mode = match(line, /^([^ \t]+)/);
|
||||
if (mode && substr(mode[1], 0, 1) !== '-') {
|
||||
errors = [ ...errors, `备份文件内包含不支持的条目类型: ${path}` ];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path === 'homeproxy-backup-manifest.json')
|
||||
continue;
|
||||
|
||||
if (!allowedBackupPath(path))
|
||||
errors = [ ...errors, `备份文件内包含不支持的路径: ${path}` ];
|
||||
else
|
||||
files = [ ...files, path ];
|
||||
}
|
||||
|
||||
fd.close();
|
||||
|
||||
if (length(errors))
|
||||
return { valid: false, error: join('\n', errors), files };
|
||||
|
||||
if (index(files, 'etc/config/homeproxy') === -1)
|
||||
return { valid: false, error: '备份文件缺少 etc/config/homeproxy', files };
|
||||
|
||||
return { valid: true, files };
|
||||
}
|
||||
|
||||
function createBackupArchive(path) {
|
||||
let files = [];
|
||||
|
||||
if (stat('/etc/config/homeproxy')?.type === 'file')
|
||||
files = [ ...files, 'etc/config/homeproxy' ];
|
||||
|
||||
if (stat(`${HP_DIR}/resources/direct_list.txt`)?.type === 'file')
|
||||
files = [ ...files, 'etc/homeproxy/resources/direct_list.txt' ];
|
||||
|
||||
if (stat(`${HP_DIR}/resources/proxy_list.txt`)?.type === 'file')
|
||||
files = [ ...files, 'etc/homeproxy/resources/proxy_list.txt' ];
|
||||
|
||||
const certs = commandOutput(`[ -d /etc/homeproxy/certs ] && find /etc/homeproxy/certs -maxdepth 1 -type f -print || true`);
|
||||
for (let file in split(trim(certs), /\n/)) {
|
||||
file = archiveMemberPath(file);
|
||||
if (allowedBackupPath(file))
|
||||
files = [ ...files, file ];
|
||||
}
|
||||
|
||||
if (!length(files))
|
||||
return { result: false, error: '没有可备份的 HomeProxy 文件' };
|
||||
|
||||
const work_dir = createWorkDir();
|
||||
const staging_root = `${work_dir}/staging`;
|
||||
system(`mkdir -p ${shellquote(staging_root)}`);
|
||||
|
||||
for (let file in files) {
|
||||
let src = '/' + file;
|
||||
let dst = `${staging_root}/${file}`;
|
||||
let dst_dir = match(dst, /^(.*)\/[^\/]+$/);
|
||||
|
||||
if (dst_dir && dst_dir[1])
|
||||
system(`mkdir -p ${shellquote(dst_dir[1])}`);
|
||||
|
||||
let exit_code = system(`cp ${shellquote(src)} ${shellquote(dst)}`);
|
||||
if (exit_code !== 0) {
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
return { result: false, error: `复制文件失败: ${file}` };
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = {};
|
||||
for (let file in files) {
|
||||
let fullpath = `${staging_root}/${file}`;
|
||||
let filestat = stat(fullpath);
|
||||
if (filestat?.type === 'file') {
|
||||
let hash = trim(commandOutput(`sha256sum ${shellquote(fullpath)} 2>/dev/null | awk '{print $1}'`));
|
||||
manifest[file] = {
|
||||
size: filestat.size,
|
||||
sha256: hash
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const manifest_file = `${staging_root}/homeproxy-backup-manifest.json`;
|
||||
writefile(manifest_file, sprintf('%J', manifest));
|
||||
|
||||
const backup_list = `${work_dir}/backup.list`;
|
||||
let all_files = [ ...files, 'homeproxy-backup-manifest.json' ];
|
||||
writefile(backup_list, join('\n', all_files) + '\n');
|
||||
|
||||
const temp_tar = `${work_dir}/archive.tar`;
|
||||
const temp_gz = `${work_dir}/archive.tar.gz`;
|
||||
let exit_code = system(`cd ${shellquote(staging_root)} && umask 077 && /bin/tar -cf ${shellquote(temp_tar)} -T ${shellquote(backup_list)}`);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
return { result: false, error: `tar 打包失败,退出码 ${exit_code}` };
|
||||
}
|
||||
|
||||
exit_code = system(`umask 077 && gzip -c ${shellquote(temp_tar)} > ${shellquote(temp_gz)}`);
|
||||
if (exit_code !== 0) {
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
return { result: false, error: `压缩失败,退出码 ${exit_code}` };
|
||||
}
|
||||
|
||||
system(`rm -f ${shellquote(path)}`);
|
||||
exit_code = system(`umask 077 && mv ${shellquote(temp_gz)} ${shellquote(path)} && chmod 600 ${shellquote(path)}`);
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
system(`rm -f ${shellquote(path)}`);
|
||||
return { result: false, error: `生成备份文件失败,退出码 ${exit_code}` };
|
||||
}
|
||||
|
||||
const archive = lstat(path);
|
||||
if (!archive || archive.type !== 'file' || archive.size <= 0)
|
||||
return { result: false, error: '备份文件未成功生成' };
|
||||
|
||||
return {
|
||||
result: true,
|
||||
path,
|
||||
size: archive.size,
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
function testExtractBackupArchive(path) {
|
||||
const restore_test_dir = createWorkDir();
|
||||
let exit_code = system(`cd ${shellquote(restore_test_dir)} && /bin/tar -xzf ${shellquote(path)} 2>/dev/null`);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: `备份文件损坏,无法完整解压,退出码 ${exit_code}` };
|
||||
}
|
||||
|
||||
const manifest_path = `${restore_test_dir}/homeproxy-backup-manifest.json`;
|
||||
const manifest_content = readfile(manifest_path);
|
||||
|
||||
if (!manifest_content) {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: '备份文件缺少完整性清单(homeproxy-backup-manifest.json)' };
|
||||
}
|
||||
|
||||
let manifest = {};
|
||||
try {
|
||||
manifest = json(manifest_content);
|
||||
} catch (e) {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: '备份清单格式错误' };
|
||||
}
|
||||
|
||||
for (let file in manifest) {
|
||||
if (file === 'homeproxy-backup-manifest.json') {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: 'manifest 不能包含自身作为恢复目标' };
|
||||
}
|
||||
|
||||
if (!allowedBackupPath(file)) {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: `manifest 包含不允许的路径: ${file}` };
|
||||
}
|
||||
|
||||
let fullpath = `${restore_test_dir}/${file}`;
|
||||
let filestat = stat(fullpath);
|
||||
|
||||
if (!filestat || filestat.type !== 'file') {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: `备份文件缺失: ${file}` };
|
||||
}
|
||||
|
||||
if (filestat.size !== manifest[file].size) {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: `备份文件大小不匹配: ${file}` };
|
||||
}
|
||||
|
||||
let hash = trim(commandOutput(`sha256sum ${shellquote(fullpath)} 2>/dev/null | awk '{print $1}'`));
|
||||
if (hash !== manifest[file].sha256) {
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: `备份文件校验和不匹配: ${file}` };
|
||||
}
|
||||
}
|
||||
|
||||
const fd = popen(`/bin/tar -tzf ${shellquote(path)} 2>&1`);
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
let archive_path = archiveMemberPath(line);
|
||||
|
||||
if (!archive_path || archive_path === 'homeproxy-backup-manifest.json')
|
||||
continue;
|
||||
|
||||
if (allowedBackupPath(archive_path) && !manifest[archive_path]) {
|
||||
fd.close();
|
||||
system(`rm -rf ${shellquote(restore_test_dir)}`);
|
||||
return { result: false, error: `归档中存在未记录的可恢复文件: ${archive_path}` };
|
||||
}
|
||||
}
|
||||
fd.close();
|
||||
}
|
||||
|
||||
return { result: true, manifest: manifest, work_dir: restore_test_dir };
|
||||
}
|
||||
|
||||
function resetBackupTargetPaths() {
|
||||
system('rm -f /etc/config/homeproxy /etc/homeproxy/resources/direct_list.txt /etc/homeproxy/resources/proxy_list.txt');
|
||||
system('rm -rf /etc/homeproxy/certs && mkdir -p /etc/homeproxy/resources /etc/homeproxy/certs');
|
||||
}
|
||||
|
||||
function extractBackupArchive(work_dir, manifest) {
|
||||
resetBackupTargetPaths();
|
||||
|
||||
for (let file in manifest) {
|
||||
let src = `${work_dir}/${file}`;
|
||||
let dst = `/${file}`;
|
||||
let dst_dir = match(dst, /^(.*)\/[^\/]+$/);
|
||||
|
||||
if (dst_dir && dst_dir[1])
|
||||
system(`mkdir -p ${shellquote(dst_dir[1])}`);
|
||||
|
||||
let exit_code = system(`cp ${shellquote(src)} ${shellquote(dst)}`);
|
||||
if (exit_code !== 0)
|
||||
return exit_code;
|
||||
|
||||
if (match(file, /^etc\/homeproxy\/certs\/[^\/]+$/))
|
||||
system(`chmod 600 ${shellquote(dst)}`);
|
||||
else if (file === 'etc/config/homeproxy')
|
||||
system(`chmod 600 ${shellquote(dst)}`);
|
||||
else
|
||||
system(`chmod 644 ${shellquote(dst)}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
backup_create: {
|
||||
call: function() {
|
||||
BACKUP_ARCHIVE = '/tmp/homeproxy-backup-' + generateTempSuffix() + '.tar.gz';
|
||||
let result = createBackupArchive(BACKUP_ARCHIVE);
|
||||
if (result.result)
|
||||
result.download_path = BACKUP_ARCHIVE;
|
||||
return result;
|
||||
}
|
||||
},
|
||||
backup_get_upload_path: {
|
||||
call: function() {
|
||||
const upload_path = '/tmp/homeproxy-restore-' + generateTempSuffix() + '.tar.gz';
|
||||
return { upload_path: upload_path };
|
||||
}
|
||||
},
|
||||
backup_cleanup: {
|
||||
args: { path: 'path' },
|
||||
call: function(req) {
|
||||
const path = req.args?.path;
|
||||
|
||||
if (!path || !match(path, /^\/tmp\/homeproxy-(backup|restore)-[a-f0-9]+\.tar\.gz$/))
|
||||
return { result: false, error: '无效的临时文件路径' };
|
||||
|
||||
system(`rm -f ${shellquote(path)}`);
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
backup_validate: {
|
||||
args: { path: 'path' },
|
||||
call: function(req) {
|
||||
const path = req.args?.path;
|
||||
|
||||
if (!path || !match(path, /^\/tmp\/homeproxy-restore-[a-f0-9]+\.tar\.gz$/))
|
||||
return { valid: false, error: '无效的备份文件路径' };
|
||||
|
||||
const check = validateBackupArchive(path);
|
||||
if (!check.valid)
|
||||
return check;
|
||||
|
||||
const integrity = testExtractBackupArchive(path);
|
||||
if (integrity.work_dir)
|
||||
system(`rm -rf ${shellquote(integrity.work_dir)}`);
|
||||
if (!integrity.result)
|
||||
return { valid: false, error: integrity.error, files: check.files };
|
||||
|
||||
return check;
|
||||
}
|
||||
},
|
||||
backup_restore: {
|
||||
args: { path: 'path' },
|
||||
call: function(req) {
|
||||
const restore_path = req.args?.path;
|
||||
|
||||
if (!restore_path)
|
||||
return { result: false, error: '缺少备份文件路径,请重新上传并验证备份文件' };
|
||||
|
||||
if (!match(restore_path, /^\/tmp\/homeproxy-restore-[a-f0-9]+\.tar\.gz$/))
|
||||
return { result: false, error: '无效的备份文件路径' };
|
||||
|
||||
const check = validateBackupArchive(restore_path);
|
||||
|
||||
if (!check.valid)
|
||||
return { result: false, error: check.error, files: check.files };
|
||||
|
||||
const extract_check = testExtractBackupArchive(restore_path);
|
||||
if (!extract_check.result)
|
||||
return { result: false, error: extract_check.error, files: check.files };
|
||||
|
||||
const manifest = extract_check.manifest;
|
||||
const work_dir = extract_check.work_dir;
|
||||
|
||||
const rollback_archive = '/tmp/homeproxy-rollback-' + generateTempSuffix() + '.tar.gz';
|
||||
const rollback = createBackupArchive(rollback_archive);
|
||||
if (!rollback.result) {
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
return { result: false, error: `创建回滚包失败: ${rollback.error}` };
|
||||
}
|
||||
|
||||
const rollback_extract_check = testExtractBackupArchive(rollback_archive);
|
||||
if (!rollback_extract_check.result) {
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
return { result: false, error: `回滚包校验失败: ${rollback_extract_check.error}` };
|
||||
}
|
||||
|
||||
const rollback_manifest = rollback_extract_check.manifest;
|
||||
const rollback_work_dir = rollback_extract_check.work_dir;
|
||||
|
||||
let exit_code = extractBackupArchive(work_dir, manifest);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
system(`rm -rf ${shellquote(work_dir)}`);
|
||||
let rollback_code = extractBackupArchive(rollback_work_dir, rollback_manifest);
|
||||
system(`rm -rf ${shellquote(rollback_work_dir)}`);
|
||||
system('/sbin/uci commit homeproxy');
|
||||
system('/etc/init.d/homeproxy restart >/dev/null 2>&1');
|
||||
|
||||
if (rollback_code === 0)
|
||||
return { result: false, error: `恢复失败,已自动回滚,退出码 ${exit_code}`, rollback: rollback_archive };
|
||||
|
||||
return { result: false, error: `恢复失败且自动回滚失败,恢复退出码 ${exit_code},回滚退出码 ${rollback_code}`, rollback: rollback_archive };
|
||||
}
|
||||
|
||||
system(`ucode ${HP_DIR}/scripts/migrate_config.uc >/dev/null 2>&1`);
|
||||
|
||||
let adjustments = normalizeRestoredLocalControllers();
|
||||
system('/sbin/uci commit homeproxy');
|
||||
system(`rm -f ${shellquote(restore_path)} /tmp/luci-indexcache.*`);
|
||||
system('rm -rf /tmp/luci-modulecache/ 2>/dev/null');
|
||||
system('/etc/init.d/homeproxy restart >/dev/null 2>&1');
|
||||
|
||||
sleep(2000);
|
||||
let service_check = validateRestoredService();
|
||||
|
||||
if (!service_check.result) {
|
||||
let rollback_code = extractBackupArchive(rollback_work_dir, rollback_manifest);
|
||||
system(`rm -rf ${shellquote(work_dir)} ${shellquote(rollback_work_dir)}`);
|
||||
system('/sbin/uci commit homeproxy');
|
||||
system('/etc/init.d/homeproxy restart >/dev/null 2>&1');
|
||||
|
||||
if (rollback_code === 0)
|
||||
return { result: false, error: `${service_check.error},已自动回滚`, rollback: rollback_archive };
|
||||
|
||||
return { result: false, error: `${service_check.error},且自动回滚失败`, rollback: rollback_archive };
|
||||
}
|
||||
|
||||
system(`rm -rf ${shellquote(work_dir)} ${shellquote(rollback_work_dir)}`);
|
||||
|
||||
return {
|
||||
result: true,
|
||||
files: check.files,
|
||||
rollback: rollback_archive,
|
||||
adjustments
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.homeproxy_backup': methods };
|
||||
@@ -1,270 +0,0 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 节点和订阅 helper RPC。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import {
|
||||
node_filter_compile,
|
||||
expand_node_filter,
|
||||
preview_manual_node_allowed
|
||||
} from '/etc/homeproxy/scripts/node_filter.uc';
|
||||
import {
|
||||
collect_node_references,
|
||||
remove_subscription_nodes
|
||||
} from '/etc/homeproxy/scripts/node_references.uc';
|
||||
import { first_value } from '/etc/homeproxy/scripts/clash_api.uc';
|
||||
|
||||
function shellquote(s) {
|
||||
return `'${replace(s, "'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
const HP_DIR = '/etc/homeproxy';
|
||||
const RUN_DIR = '/var/run/homeproxy';
|
||||
const CONFIG_DIAGNOSTICS = `${RUN_DIR}/config-diagnostics.json`;
|
||||
const SUBSCRIPTION_DIAGNOSTICS = `${RUN_DIR}/subscription-diagnostics.json`;
|
||||
const SUBSCRIPTION_UPDATE_STATUS = `${RUN_DIR}/subscription-update-status.json`;
|
||||
const MAX_NODE_IDS = 1000;
|
||||
const MAX_NODE_ID_LEN = 128;
|
||||
|
||||
function isEmpty(value) {
|
||||
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
|
||||
}
|
||||
|
||||
function readDiagnostics(path) {
|
||||
let content = readfile(path);
|
||||
|
||||
if (!content)
|
||||
return { result: true, time: null, items: [] };
|
||||
|
||||
try {
|
||||
let payload = json(content);
|
||||
return {
|
||||
result: true,
|
||||
time: payload.time,
|
||||
items: payload.items || []
|
||||
};
|
||||
} catch (e) {
|
||||
return { result: false, error: '诊断文件格式错误' };
|
||||
}
|
||||
}
|
||||
|
||||
function combinedDiagnostics() {
|
||||
let items = [],
|
||||
latest_time = null;
|
||||
|
||||
for (let path in [ CONFIG_DIAGNOSTICS, SUBSCRIPTION_DIAGNOSTICS ]) {
|
||||
let payload = readDiagnostics(path);
|
||||
if (!payload.result)
|
||||
return payload;
|
||||
|
||||
if (payload.time && (!latest_time || payload.time > latest_time))
|
||||
latest_time = payload.time;
|
||||
|
||||
for (let item in payload.items)
|
||||
push(items, item);
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
time: latest_time,
|
||||
items
|
||||
};
|
||||
}
|
||||
|
||||
function readSubscriptionUpdateStatus() {
|
||||
let content = readfile(SUBSCRIPTION_UPDATE_STATUS);
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
result: true,
|
||||
running: false,
|
||||
completed: false,
|
||||
update_result: null,
|
||||
error: null,
|
||||
time: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let payload = json(content);
|
||||
return {
|
||||
result: true,
|
||||
running: !!payload.running,
|
||||
completed: !!payload.completed,
|
||||
update_result: payload.update_result,
|
||||
error: payload.error,
|
||||
time: payload.time
|
||||
};
|
||||
} catch (e) {
|
||||
return { result: false, error: '订阅更新状态文件格式错误' };
|
||||
}
|
||||
}
|
||||
|
||||
function writeSubscriptionUpdateStatus(payload) {
|
||||
system(`mkdir -p ${shellquote(RUN_DIR)}`);
|
||||
writefile(SUBSCRIPTION_UPDATE_STATUS, sprintf('%.J\n', payload));
|
||||
}
|
||||
|
||||
function validNodeId(node_id) {
|
||||
return type(node_id) === 'string' &&
|
||||
length(node_id) > 0 &&
|
||||
length(node_id) <= MAX_NODE_ID_LEN &&
|
||||
match(node_id, /^[A-Za-z0-9_]+$/);
|
||||
}
|
||||
|
||||
function validateNodeIds(node_ids) {
|
||||
if (type(node_ids) !== 'array')
|
||||
return { result: false, error: 'illegal node_ids' };
|
||||
|
||||
if (length(node_ids) > MAX_NODE_IDS)
|
||||
return { result: false, error: 'too many node_ids' };
|
||||
|
||||
for (let node_id in node_ids)
|
||||
if (!validNodeId(node_id))
|
||||
return { result: false, error: 'illegal node id' };
|
||||
|
||||
return { result: true };
|
||||
}
|
||||
|
||||
function expandNodeFilterPreview(manual_nodes, node_filter, node_filter_exclude, node_mode) {
|
||||
const hpuci = cursor();
|
||||
hpuci.load('homeproxy');
|
||||
|
||||
return expand_node_filter({
|
||||
uci: hpuci,
|
||||
config: 'homeproxy',
|
||||
node_type: 'node',
|
||||
allow_manual_node: (node_id) => preview_manual_node_allowed(hpuci, 'homeproxy', node_id, node_mode)
|
||||
}, manual_nodes, node_filter, node_filter_exclude);
|
||||
}
|
||||
|
||||
const methods = {
|
||||
config_diagnostics: {
|
||||
args: {},
|
||||
call: function(_req) {
|
||||
return combinedDiagnostics();
|
||||
}
|
||||
},
|
||||
update_subscriptions_status: {
|
||||
args: {},
|
||||
call: function(_req) {
|
||||
return readSubscriptionUpdateStatus();
|
||||
}
|
||||
},
|
||||
validate_regex: {
|
||||
args: { pattern: 'pattern' },
|
||||
call: function(req) {
|
||||
const pattern = first_value(req.args?.pattern);
|
||||
const validated = node_filter_compile(pattern);
|
||||
|
||||
if (!validated.result)
|
||||
return { result: false, error: validated.error, limits: validated.limits };
|
||||
|
||||
return { result: true, limits: validated.limits };
|
||||
}
|
||||
},
|
||||
preview_node_filter: {
|
||||
args: { manual_nodes: [], node_filter: 'node_filter', node_filter_exclude: 'node_filter_exclude', node: 'node' },
|
||||
call: function(req) {
|
||||
return expandNodeFilterPreview(
|
||||
req.args?.manual_nodes,
|
||||
first_value(req.args?.node_filter),
|
||||
first_value(req.args?.node_filter_exclude),
|
||||
first_value(req.args?.node)
|
||||
);
|
||||
}
|
||||
},
|
||||
node_references: {
|
||||
args: { node_id: 'node_id' },
|
||||
call: function(req) {
|
||||
const node_id = first_value(req.args?.node_id);
|
||||
|
||||
if (!validNodeId(node_id))
|
||||
return { result: false, error: 'illegal node_id' };
|
||||
|
||||
const hpuci = cursor();
|
||||
hpuci.load('homeproxy');
|
||||
|
||||
return {
|
||||
result: true,
|
||||
node_id: node_id,
|
||||
references: collect_node_references(hpuci, 'homeproxy', node_id)
|
||||
};
|
||||
}
|
||||
},
|
||||
remove_subscription_nodes: {
|
||||
args: { node_ids: [] },
|
||||
call: function(req) {
|
||||
const checked = validateNodeIds(req.args?.node_ids);
|
||||
if (!checked.result)
|
||||
return checked;
|
||||
|
||||
const hpuci = cursor();
|
||||
hpuci.load('homeproxy');
|
||||
|
||||
let result = remove_subscription_nodes(hpuci, 'homeproxy', req.args.node_ids);
|
||||
hpuci.commit('homeproxy');
|
||||
|
||||
return {
|
||||
result: true,
|
||||
removed: result.removed,
|
||||
changed: result.changed,
|
||||
changes: result.changes,
|
||||
main_node_fallback: result.main_node_fallback,
|
||||
main_udp_fallback: result.main_udp_fallback
|
||||
};
|
||||
}
|
||||
},
|
||||
update_subscriptions: {
|
||||
args: {},
|
||||
call: function(_req) {
|
||||
const status = readSubscriptionUpdateStatus();
|
||||
if (!status.result)
|
||||
return status;
|
||||
|
||||
if (status.running) {
|
||||
return {
|
||||
result: false,
|
||||
error: '订阅更新正在进行中,请稍候。'
|
||||
};
|
||||
}
|
||||
|
||||
writeSubscriptionUpdateStatus({
|
||||
time: time(),
|
||||
running: true,
|
||||
completed: false,
|
||||
update_result: null,
|
||||
error: null
|
||||
});
|
||||
|
||||
const exit_code = system(`(${HP_DIR}/scripts/update_subscriptions.uc </dev/null >/dev/null 2>&1) &`);
|
||||
if (exit_code !== 0) {
|
||||
writeSubscriptionUpdateStatus({
|
||||
time: time(),
|
||||
running: false,
|
||||
completed: true,
|
||||
update_result: false,
|
||||
error: '启动订阅更新失败,请查看 HomeProxy 日志。'
|
||||
});
|
||||
|
||||
return {
|
||||
result: false,
|
||||
error: '启动订阅更新失败,请查看 HomeProxy 日志。'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
started: true
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.homeproxy_node_tools': methods };
|
||||
@@ -1,416 +0,0 @@
|
||||
#!/usr/bin/ucode
|
||||
'use strict';
|
||||
|
||||
import { popen, readfile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { urlencode } from 'luci.http';
|
||||
|
||||
import { build_outbound_tag_map, get_outbound_tag } from '/etc/homeproxy/scripts/outbound_tag.uc';
|
||||
import { parse_controller } from '/etc/homeproxy/scripts/clash_api.uc';
|
||||
|
||||
const TCPING_MAX_NODES = 300;
|
||||
const SECTION_MAX_LEN = 128;
|
||||
const BATCH_SIZE = 50;
|
||||
const TCPING_COOLDOWN = 3;
|
||||
const DELAY_TIMEOUT = 5000;
|
||||
const FETCH_TIMEOUT = 30000;
|
||||
const DELAY_TEST_URL = 'https://www.gstatic.com/generate_204';
|
||||
|
||||
let last_tcping_at = 0;
|
||||
|
||||
// urlencode() 只负责 URL/path 编码,不能作为 shell 注入防护;所有进入 shell 命令的动态值都必须继续经过 shellquote()。
|
||||
function shellquote(s) {
|
||||
return `'${replace(s, "'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function pathSegment(value) {
|
||||
return replace(urlencode(value), /\+/g, '%20');
|
||||
}
|
||||
|
||||
function controllerBase(hpuci) {
|
||||
let controller = parse_controller(
|
||||
hpuci.get('homeproxy', 'config', 'clash_api_external_controller') || '127.0.0.1:9090',
|
||||
9090
|
||||
);
|
||||
let port_num = controller.port || 9090;
|
||||
|
||||
if (port_num < 1 || port_num > 65535)
|
||||
return null;
|
||||
|
||||
return 'http://127.0.0.1:' + port_num;
|
||||
}
|
||||
|
||||
function clashApiGet(hpuci, path, timeout) {
|
||||
let timeout_sec = int(((timeout || FETCH_TIMEOUT) + 999) / 1000),
|
||||
controller = controllerBase(hpuci),
|
||||
secret = hpuci.get('homeproxy', 'config', 'clash_api_secret') || '';
|
||||
|
||||
if (!controller)
|
||||
return {};
|
||||
|
||||
let command = sprintf('/usr/bin/wget -qO- -T %d --no-proxy %s %s',
|
||||
timeout_sec,
|
||||
secret ? sprintf('--header %s', shellquote('Authorization: Bearer ' + secret)) : '',
|
||||
shellquote(controller + path)),
|
||||
fd = popen(command + ' 2>/dev/null'),
|
||||
out = '';
|
||||
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line'))
|
||||
out += line;
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
try {
|
||||
return json(trim(out) || '{}');
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function delayQuery() {
|
||||
return sprintf('?timeout=%d&url=%s',
|
||||
DELAY_TIMEOUT,
|
||||
urlencode(DELAY_TEST_URL));
|
||||
}
|
||||
|
||||
function proxyDelayPath(name) {
|
||||
return sprintf('/proxies/%s/delay%s',
|
||||
pathSegment(name),
|
||||
delayQuery());
|
||||
}
|
||||
|
||||
function delayRequestTimeout() {
|
||||
let timeout = DELAY_TIMEOUT * 2 + 10000;
|
||||
|
||||
return (timeout > FETCH_TIMEOUT) ? timeout : FETCH_TIMEOUT;
|
||||
}
|
||||
|
||||
function parseProxyDelayResult(result) {
|
||||
let error = '';
|
||||
|
||||
if (type(result) !== 'object')
|
||||
return { status: 'failed', error: '连通性测试无结果' };
|
||||
|
||||
error = result.message || result.error || '';
|
||||
|
||||
if (type(result.delay) === 'int' || type(result.delay) === 'double')
|
||||
return { status: 'ok', delay: int(result.delay) };
|
||||
|
||||
return { status: 'timeout', error: error || '连通性测试超时或失败' };
|
||||
}
|
||||
|
||||
function stampResult(result) {
|
||||
let now = time();
|
||||
result.tested_at = now;
|
||||
return result;
|
||||
}
|
||||
|
||||
function reserveTcpingSlot() {
|
||||
let now = time(),
|
||||
wait = TCPING_COOLDOWN - (now - last_tcping_at);
|
||||
|
||||
if (last_tcping_at > 0 && wait > 0)
|
||||
return {
|
||||
result: false,
|
||||
error: sprintf('测速请求过于频繁,请 %d 秒后重试。', wait)
|
||||
};
|
||||
|
||||
last_tcping_at = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadedProxies(hpuci) {
|
||||
return clashApiGet(hpuci, '/proxies', FETCH_TIMEOUT);
|
||||
}
|
||||
|
||||
function loadedProxyTags(payload) {
|
||||
let tags = {};
|
||||
|
||||
if (type(payload) === 'object' && type(payload.proxies) === 'object')
|
||||
for (let name in payload.proxies)
|
||||
tags[name] = true;
|
||||
else
|
||||
return null;
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
function nodeTag(tag_map, section) {
|
||||
return get_outbound_tag(tag_map, section);
|
||||
}
|
||||
|
||||
function runtimeNodeTag(hpuci, tag_map, section) {
|
||||
let routing_mode = hpuci.get('homeproxy', 'config', 'routing_mode') || 'bypass_mainland_china',
|
||||
main_node = hpuci.get('homeproxy', 'config', 'main_node') || 'nil',
|
||||
main_udp_node = hpuci.get('homeproxy', 'config', 'main_udp_node') || 'nil';
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
if (section === main_node)
|
||||
return 'main-out';
|
||||
|
||||
if (main_udp_node !== 'nil' &&
|
||||
main_udp_node !== 'same' &&
|
||||
main_udp_node !== main_node &&
|
||||
section === main_udp_node)
|
||||
return 'main-udp-out';
|
||||
}
|
||||
|
||||
return nodeTag(tag_map, section);
|
||||
}
|
||||
|
||||
function validSectionName(section) {
|
||||
return type(section) === 'string' &&
|
||||
section !== '' &&
|
||||
length(section) <= SECTION_MAX_LEN &&
|
||||
match(section, /^[A-Za-z0-9_]+$/);
|
||||
}
|
||||
|
||||
function resolveNode(hpuci, tag_map, section) {
|
||||
if (!validSectionName(section))
|
||||
return { result: false, error: 'illegal section' };
|
||||
|
||||
let cfg = hpuci.get_all('homeproxy', section),
|
||||
tag = runtimeNodeTag(hpuci, tag_map, section);
|
||||
|
||||
if (!cfg || cfg['.type'] !== 'node')
|
||||
return { result: true, node: stampResult({ status: 'missing', target: tag, error: '节点不存在' }) };
|
||||
|
||||
if (cfg.type in ['direct', 'block'])
|
||||
return {
|
||||
result: true,
|
||||
node: stampResult({
|
||||
status: 'unsupported',
|
||||
target: tag,
|
||||
error: '直连或阻断节点不测速'
|
||||
})
|
||||
};
|
||||
|
||||
return { result: true, node: null, tag };
|
||||
}
|
||||
|
||||
function testNodeByProxyDelay(hpuci, payload, tag) {
|
||||
let loaded = loadedProxyTags(payload);
|
||||
|
||||
if (loaded === null)
|
||||
return stampResult({ status: 'failed', target: tag, error: 'Clash API 未响应,请确认 HomeProxy 客户端正在运行。' });
|
||||
|
||||
if (!(tag in loaded))
|
||||
return stampResult({ status: 'unloaded', target: tag, error: '节点未加入当前运行配置' });
|
||||
|
||||
let result = stampResult(parseProxyDelayResult(clashApiGet(hpuci, proxyDelayPath(tag), delayRequestTimeout())));
|
||||
|
||||
result.target = tag;
|
||||
return result;
|
||||
}
|
||||
|
||||
function testNodesByProxyDelayBatch(hpuci, payload, tags) {
|
||||
let results = {},
|
||||
loaded = loadedProxyTags(payload);
|
||||
|
||||
if (loaded === null) {
|
||||
for (let tag in tags)
|
||||
results[tag] = stampResult({
|
||||
status: 'failed',
|
||||
target: tag,
|
||||
error: 'Clash API 未响应,请确认 HomeProxy 客户端正在运行。'
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
let pending = [];
|
||||
|
||||
for (let tag in tags) {
|
||||
if (!(tag in loaded)) {
|
||||
results[tag] = stampResult({
|
||||
status: 'unloaded',
|
||||
target: tag,
|
||||
error: '节点未加入当前运行配置'
|
||||
});
|
||||
} else {
|
||||
push(pending, tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!length(pending))
|
||||
return results;
|
||||
|
||||
let timeout_sec = int((delayRequestTimeout() + 999) / 1000),
|
||||
controller = controllerBase(hpuci),
|
||||
secret = hpuci.get('homeproxy', 'config', 'clash_api_secret') || '',
|
||||
header = secret ? sprintf('--header %s', shellquote('Authorization: Bearer ' + secret)) : '',
|
||||
temp_dir_cmd = 'mktemp -d /tmp/homeproxy-delay.XXXXXX',
|
||||
fd = popen(temp_dir_cmd + ' 2>/dev/null'),
|
||||
temp_dir = '';
|
||||
|
||||
if (fd) {
|
||||
temp_dir = trim(fd.read('line') || '');
|
||||
fd.close();
|
||||
}
|
||||
|
||||
if (!temp_dir) {
|
||||
for (let tag in pending)
|
||||
results[tag] = stampResult({ status: 'failed', target: tag, error: '无法创建测速临时目录' });
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < length(pending); offset += BATCH_SIZE) {
|
||||
let commands = [];
|
||||
|
||||
for (let i = offset; i < length(pending) && i < offset + BATCH_SIZE; i++) {
|
||||
let tag = pending[i],
|
||||
url = controller + proxyDelayPath(tag),
|
||||
out = temp_dir + '/' + i + '.json';
|
||||
|
||||
// url 与输出路径来自动态值,即使 URL 已编码,拼接 shell 命令前仍必须 shellquote()。
|
||||
push(commands, sprintf('( /usr/bin/wget -qO- -T %d --no-proxy %s %s > %s 2>/dev/null ) &',
|
||||
timeout_sec,
|
||||
header,
|
||||
shellquote(url),
|
||||
shellquote(out)));
|
||||
}
|
||||
|
||||
push(commands, 'wait');
|
||||
system(join(' ', commands));
|
||||
}
|
||||
|
||||
for (let i = 0; i < length(pending); i++) {
|
||||
let tag = pending[i],
|
||||
body = trim(readfile(temp_dir + '/' + i + '.json') || ''),
|
||||
parsed = {};
|
||||
|
||||
try {
|
||||
parsed = json(body || '{}');
|
||||
} catch (e) {
|
||||
parsed = {};
|
||||
}
|
||||
|
||||
results[tag] = stampResult(parseProxyDelayResult(parsed));
|
||||
results[tag].target = tag;
|
||||
}
|
||||
|
||||
system('rm -rf ' + shellquote(temp_dir));
|
||||
return results;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
node_tcping: {
|
||||
args: { section: '' },
|
||||
call: function(req) {
|
||||
let section = req.args ? req.args.section : null;
|
||||
|
||||
const hpuci = cursor();
|
||||
hpuci.load('homeproxy');
|
||||
|
||||
let tag_map = build_outbound_tag_map(hpuci),
|
||||
resolved = resolveNode(hpuci, tag_map, section);
|
||||
|
||||
if (!resolved.result || resolved.node)
|
||||
return resolved;
|
||||
|
||||
if (hpuci.get('homeproxy', 'config', 'clash_api_enabled') !== '1')
|
||||
return { result: false, error: '请先开启 Clash API 后再进行连通性测试。' };
|
||||
|
||||
if (!controllerBase(hpuci))
|
||||
return { result: false, error: 'Clash API 端口无效,请设置为 1-65535。' };
|
||||
|
||||
let cooldown = reserveTcpingSlot();
|
||||
if (cooldown)
|
||||
return cooldown;
|
||||
|
||||
let payload = loadedProxies(hpuci);
|
||||
|
||||
return {
|
||||
result: true,
|
||||
node: testNodeByProxyDelay(hpuci, payload, resolved.tag)
|
||||
};
|
||||
}
|
||||
},
|
||||
nodes_tcping: {
|
||||
args: { sections: [] },
|
||||
call: function(req) {
|
||||
let sections = req.args ? req.args.sections : null;
|
||||
if (type(sections) !== 'array')
|
||||
return { result: false, error: 'illegal sections' };
|
||||
|
||||
const hpuci = cursor();
|
||||
let nodes = {},
|
||||
tested = 0,
|
||||
tags = [],
|
||||
seen_sections = {},
|
||||
warning = '';
|
||||
|
||||
hpuci.load('homeproxy');
|
||||
let tag_map = build_outbound_tag_map(hpuci);
|
||||
|
||||
for (let i = 0; i < length(sections); i++) {
|
||||
let section = sections[i];
|
||||
if (seen_sections[section])
|
||||
continue;
|
||||
seen_sections[section] = true;
|
||||
|
||||
if (++tested > TCPING_MAX_NODES)
|
||||
nodes[section] = { status: 'skipped', error: sprintf('一次最多测试 %d 个节点', TCPING_MAX_NODES) };
|
||||
else {
|
||||
let resolved = resolveNode(hpuci, tag_map, section);
|
||||
|
||||
if (!resolved.result)
|
||||
return { result: false, error: resolved.error };
|
||||
|
||||
if (resolved.node)
|
||||
nodes[section] = resolved.node;
|
||||
else
|
||||
push(tags, resolved.tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (length(tags)) {
|
||||
if (hpuci.get('homeproxy', 'config', 'clash_api_enabled') !== '1')
|
||||
return { result: false, error: '请先开启 Clash API 后再进行连通性测试。' };
|
||||
|
||||
if (!controllerBase(hpuci))
|
||||
return { result: false, error: 'Clash API 端口无效,请设置为 1-65535。' };
|
||||
|
||||
let cooldown = reserveTcpingSlot();
|
||||
if (cooldown)
|
||||
return cooldown;
|
||||
|
||||
let payload = loadedProxies(hpuci),
|
||||
tag_results = testNodesByProxyDelayBatch(hpuci, payload, tags);
|
||||
|
||||
if (loadedProxyTags(payload) === null)
|
||||
warning = 'Clash API 未响应,请确认 HomeProxy 客户端正在运行。';
|
||||
|
||||
for (let i = 0; i < length(sections); i++) {
|
||||
let section = sections[i],
|
||||
tag = runtimeNodeTag(hpuci, tag_map, section);
|
||||
|
||||
if (nodes[section])
|
||||
continue;
|
||||
|
||||
if (tag in tag_results)
|
||||
nodes[section] = tag_results[tag];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < length(sections); i++) {
|
||||
let section = sections[i];
|
||||
if (seen_sections[section] !== true)
|
||||
continue;
|
||||
if (!nodes[section])
|
||||
nodes[section] = stampResult({
|
||||
status: 'failed',
|
||||
target: nodeTag(tag_map, section),
|
||||
error: '本次未返回结果'
|
||||
});
|
||||
}
|
||||
|
||||
return { result: true, nodes, warning };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.homeproxy_tcping': methods };
|
||||
Reference in new issue
Block a user