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

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

No files matched your search

+4 -4
View File
@@ -1,17 +1,17 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=clashoo
PKG_SHORT_SHA:=e4dd968
PKG_COMMIT_DATE:=2026.10.08
PKG_SHORT_SHA:=bc76c13
PKG_COMMIT_DATE:=2026.10.11
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
PKG_RELEASE:=8
PKG_RELEASE:=9
PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=7c870e53cf5004a1000b5c8d9b8caba5cf889927
PKG_SOURCE_VERSION:=ee4c1d93df08d24d6006d5ce8547fee5d75ce99b
PKG_HASH:=skip
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
+1 -1
View File
@@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.10.9_pre3
PKG_RELEASE:=16
PKG_RELEASE:=17
# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49)
# because that is what the GitHub release asset name uses.
+1 -1
View File
@@ -1,7 +1,7 @@
config honk 'config'
option enabled '0'
option advanced '0'
option advanced '1'
option dashboard 'none'
option config_file '/etc/honk/config.dae'
+1 -1
View File
@@ -1 +1 @@
release
both
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-clashoo
PKG_VERSION:=1.31.0
PKG_RELEASE:=6
PKG_RELEASE:=7
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@@ -247,7 +247,7 @@ function clKernelUnlock() {
return view.extend({
_busy: false,
_op: null, /* 'start' | 'stop' | 'restart' | 'reload' | null */
_op: null, /* 'start' | 'stop' | null */
_opTimers: null, /* array of setTimeout IDs for cleanup */
_accessRefreshing: false,
_coreSwitchBusy: false,
@@ -789,29 +789,7 @@ return view.extend({
if (disabled) return;
return running ? self._stop() : self._start();
}
}, [E('span', { 'class': 'cl-service-knob' })]),
E('button', {
type: 'button',
'class': 'btn cbi-button-action cl-btn-sm',
title: _("Restart Service"),
disabled: (disabled || !running) ? '' : null,
click: function (ev) {
ev.preventDefault();
if (disabled || !running) return;
return self._restart();
}
}, _("Restart")),
E('button', {
type: 'button',
'class': 'btn cbi-button-action cl-btn-sm',
title: _("Reload Service"),
disabled: (disabled || !running) ? '' : null,
click: function (ev) {
ev.preventDefault();
if (disabled || !running) return;
return self._reload();
}
}, _("Reload"))
}, [E('span', { 'class': 'cl-service-knob' })])
]);
},
@@ -1966,7 +1944,7 @@ return view.extend({
// preflight: refuse start when no profile selected.
// backend select_config also defends but toast is slow, UI would flash.
if (opKey === 'start' || opKey === 'restart' || opKey === 'reload') {
if (opKey === 'start') {
var st0 = this._lastSt || {};
var hasConfig = !!(st0.config || st0.conf_path);
if (!hasConfig) {
@@ -1988,14 +1966,12 @@ return view.extend({
btn.setAttribute('aria-pressed', newRunning ? 'true' : 'false');
btn.className = 'cl-service-switch' + (newRunning ? ' is-on' : ' is-off');
}
self._showOpMsg(opKey === 'stop' ? _("Stopping…") : opKey === 'reload' ? _("Reloading…") : _("Starting…"));
self._showOpMsg(opKey === 'stop' ? _("Stopping…") : _("Starting…"));
}
var maxWait = opKey === 'stop' ? 15000 : 35000;
var started = Date.now();
var pollTimer = null;
var initialPid = (this._lastSt && this._lastSt.pid) || 0;
var sawStopped = false;
var rpcDone = false;
var finished = false;
@@ -2027,10 +2003,8 @@ return view.extend({
if (opKey === 'stop') {
if (rpcDone && st.running === false) return finish(_("Stopped") + ' ⚪');
} else {
if (st.running === false) sawStopped = true;
if (rpcDone && st.health_status === 'fail') return finish(null);
if (rpcDone && st.running === true &&
(opKey !== 'restart' || (initialPid && st.pid && st.pid !== initialPid) || sawStopped))
if (rpcDone && st.running === true)
return finish(_("Running 🟢"));
}
if (elapsed >= maxWait) return finish(null);
@@ -2044,10 +2018,8 @@ return view.extend({
pollTimer = setTimeout(pollOnce, 100);
},
_start: function () { return this._svc(function () { return clashoo.start(); }, 'start'); },
_stop: function () { return this._svc(function () { return clashoo.stop(); }, 'stop'); },
_restart: function () { return this._svc(function () { return clashoo.restart(); }, 'restart'); },
_reload: function () { return this._svc(function () { return clashoo.reload(); }, 'reload'); },
_start: function () { return this._svc(function () { return clashoo.start(); }, 'start'); },
_stop: function () { return this._svc(function () { return clashoo.stop(); }, 'stop'); },
_updSubs: function (ev) {
var btn = ev && ev.target;
+1 -1
View File
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy
PKG_VERSION:=27.919.1.14
PKG_RELEASE:=22
PKG_RELEASE:=23
define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy
@@ -12,9 +12,6 @@
'require uci';
'require ui';
/* Shared wording for the TLS / transport form blocks. Keeping the strings here
makes node.js and server.js consume a single definition instead of two
copies that have to be edited in lockstep. */
const TRANSPORT_NONE_HINT = _('No TCP transport, plain HTTP is merged into the HTTP transport.');
const TRANSPORT_HTTP_HINT = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.');
const TRANSPORT_QUIC_HINT = _('No additional encryption support: It\'s basically duplicate encryption.');
@@ -38,28 +35,23 @@ return baseclass.extend({
},
shadowsocks_encrypt_length: {
/* AEAD */
'aes-128-gcm': 0,
'aes-192-gcm': 0,
'aes-256-gcm': 0,
'chacha20-ietf-poly1305': 0,
'xchacha20-ietf-poly1305': 0,
/* AEAD 2022 */
'2022-blake3-aes-128-gcm': 16,
'2022-blake3-aes-256-gcm': 32,
'2022-blake3-chacha20-poly1305': 32
},
shadowsocks_encrypt_methods: [
/* Stream */
'none',
/* AEAD */
'aes-128-gcm',
'aes-192-gcm',
'aes-256-gcm',
'chacha20-ietf-poly1305',
'xchacha20-ietf-poly1305',
/* AEAD 2022 */
'2022-blake3-aes-128-gcm',
'2022-blake3-aes-256-gcm',
'2022-blake3-chacha20-poly1305'
@@ -102,10 +94,6 @@ return baseclass.extend({
}
}),
/* Build the transport option group shared by the node (client) and server
forms. `options.side` selects the client/server wording and the handful of
fields that only exist on one side; option names, defaults and dependency
sets are otherwise identical. */
renderTransportOptions(section, options) {
const features = options.features || {},
side = options.side || 'client';
@@ -236,10 +224,6 @@ return baseclass.extend({
return o;
},
/* Build the TLS option group shared by the node (client) and server forms.
`options.type_depends` lists the node types the TLS flag applies to,
`options.tls_forced_types` the types that force TLS on, and
`options.oninsecurechange` the client-side confirm handler. */
renderTlsOptions(section, options) {
const side = options.side || 'client';
let o;
@@ -323,7 +307,6 @@ return baseclass.extend({
},
calcStringMD5(e) {
/* Thanks to https://stackoverflow.com/a/41602636 */
let h = (a, b) => {
let c, d, e, f, g;
c = a & 2147483648;
@@ -400,7 +383,6 @@ return baseclass.extend({
if (!str)
return null;
/* Thanks to luci-app-ssr-plus */
str = str.replace(/-/g, '+').replace(/_/g, '/');
let padding = (4 - str.length % 4) % 4;
if (padding)
@@ -427,14 +409,12 @@ return baseclass.extend({
byteArr = crypto.getRandomValues(new Uint8Array(length));
switch (type) {
case 'base64':
/* Thanks to https://stackoverflow.com/questions/9267899 */
return btoa(String.fromCharCode.apply(null, byteArr));
case 'hex':
return Array.from(byteArr, (byte) =>
(byte & 255).toString(16).padStart(2, '0')
).join('');
case 'uuid':
/* Thanks to https://stackoverflow.com/a/2117523 */
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
@@ -501,7 +481,6 @@ return baseclass.extend({
},
validateBase64Key(length, section_id, value) {
/* Thanks to luci-proto-wireguard */
if (section_id && value)
if (value.length !== length || !value.match(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/) || value[length-1] !== '=')
return _('Expecting: %s').format(_('valid base64 key with %d characters').format(length));
@@ -526,7 +505,6 @@ return baseclass.extend({
else if (!value[2])
value[2] = 65535;
/* numeric comparison: string '<' does lexicographic ordering */
if (parseInt(value[1], 10) < parseInt(value[2], 10) && value[2] <= 65535)
return true;
}
@@ -87,7 +87,6 @@ return view.extend({
let features = data[1],
hosts = data[2]?.hosts;
/* Cache all configured proxy nodes, they will be called multiple times */
let proxy_nodes = {};
uci.sections(data[0], 'node', (res) => {
let nodeaddr = ((res.type === 'direct') ? res.override_address : res.address) || '',
@@ -352,8 +351,6 @@ return view.extend({
o.depends('proxy_mode', 'redirect_tun');
o.depends('proxy_mode', 'tun');
/* Custom routing settings start */
/* Routing settings start */
o = s.taboption('routing', form.SectionValue, '_routing', form.NamedSection, 'routing', 'homeproxy');
o.depends('routing_mode', 'custom');
@@ -443,9 +440,7 @@ return view.extend({
}
so.default = 'default-dns';
so.rmempty = false;
/* Routing settings end */
/* Routing nodes start */
s.tab('routing_node', _('Routing Nodes'));
o = s.taboption('routing_node', form.SectionValue, '_routing_node', form.GridSection, 'routing_node');
o.depends('routing_mode', 'custom');
@@ -614,9 +609,7 @@ return view.extend({
_('Interrupt existing connections when the selected outbound has changed.'));
so.depends('node', 'urltest');
so.modalonly = true;
/* Routing nodes end */
/* Routing rules start */
s.tab('routing_rule', _('Routing Rules'));
o = s.taboption('routing_rule', form.SectionValue, '_routing_rule', form.GridSection, 'routing_rule');
o.depends('routing_mode', 'custom');
@@ -958,9 +951,7 @@ return view.extend({
so = ss.taboption('fields_process', form.DynamicList, 'process_path_regex', _('Process path (regex)'),
_('Match process path using regular expression.'));
so.modalonly = true;
/* Routing rules end */
/* DNS settings start */
s.tab('dns', _('DNS Settings'));
o = s.taboption('dns', form.SectionValue, '_dns', form.NamedSection, 'dns', 'homeproxy');
o.depends('routing_mode', 'custom');
@@ -1015,9 +1006,7 @@ return view.extend({
_('Persist DNS cache across restarts (sing-box 1.14, replaces Store RDRC).'));
so.depends('disable_cache', '0');
so.rmempty = false;
/* DNS settings end */
/* DNS servers start */
s.tab('dns_server', _('DNS Servers'));
o = s.taboption('dns_server', form.SectionValue, '_dns_server', form.GridSection, 'dns_server');
o.depends('routing_mode', 'custom');
@@ -1138,9 +1127,7 @@ return view.extend({
so.default = 'direct-out';
so.rmempty = false;
so.editable = true;
/* DNS servers end */
/* DNS rules start */
s.tab('dns_rule', _('DNS Rules'));
o = s.taboption('dns_rule', form.SectionValue, '_dns_rule', form.GridSection, 'dns_rule');
o.depends('routing_mode', 'custom');
@@ -1470,10 +1457,7 @@ return view.extend({
so = ss.taboption('fields_process', form.DynamicList, 'process_path_regex', _('Process path (regex)'),
_('Match process path using regular expression.'));
so.modalonly = true;
/* DNS rules end */
/* Custom routing settings end */
/* Rule set settings start */
s.tab('ruleset', _('Rule Set'));
o = s.taboption('ruleset', form.SectionValue, '_ruleset', form.GridSection, 'ruleset');
o.depends('routing_mode', 'custom');
@@ -1584,15 +1568,12 @@ return view.extend({
_('Update interval of rule set.'));
so.placeholder = '1d';
so.depends('type', 'remote');
/* Rule set settings end */
/* ACL settings start */
s.tab('control', _('Access Control'));
o = s.taboption('control', form.SectionValue, '_control', form.NamedSection, 'control', 'homeproxy');
ss = o.subsection;
/* Interface control start */
ss.tab('interface', _('Interface Control'));
so = ss.taboption('interface', widgets.DeviceSelect, 'listen_interfaces', _('Listen interfaces'),
@@ -1604,9 +1585,7 @@ return view.extend({
_('Bind outbound traffic to specific interface. Leave empty to auto detect.'));
so.multiple = false;
so.noaliases = true;
/* Interface control end */
/* LAN IP policy start */
ss.tab('lan_ip_policy', _('LAN IP Policy'));
so = ss.taboption('lan_ip_policy', form.ListValue, 'lan_proxy_mode', _('Proxy filter mode'));
@@ -1649,9 +1628,7 @@ return view.extend({
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_global_proxy_mac_addrs', _('Global proxy MAC-s'), null, hosts);
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
/* LAN IP policy end */
/* WAN IP policy start */
ss.tab('wan_ip_policy', _('WAN IP Policy'));
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'));
@@ -1667,9 +1644,7 @@ return view.extend({
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv6_ips', _('Direct IPv6 IP-s'));
so.datatype = 'or(ip6addr, cidr6)';
so.depends('homeproxy.config.ipv6_support', '1');
/* WAN IP policy end */
/* Proxy domain list start */
ss.tab('proxy_domain_list', _('Proxy Domain List'));
so = ss.taboption('proxy_domain_list', form.TextValue, '_proxy_domain_list');
@@ -1699,9 +1674,7 @@ return view.extend({
return true;
}
/* Proxy domain list end */
/* Direct domain list start */
ss.tab('direct_domain_list', _('Direct Domain List'));
so = ss.taboption('direct_domain_list', form.TextValue, '_direct_domain_list');
@@ -1731,10 +1704,7 @@ return view.extend({
return true;
}
/* Direct domain list end */
/* ACL settings end */
/* DNS cache / timeout for preset routing modes (rightmost tab, stored in the dns section) */
s.tab('dns_cache', _('DNS Cache (1.14)'));
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy');
o.depends({'routing_mode': 'custom', '!reverse': true});
@@ -26,11 +26,9 @@ function parseShareLink(uri, features) {
if (uri[0] && uri[1]) {
switch (uri[0]) {
case 'anytls':
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
url = new URL('http://' + uri[1]);
params = url.searchParams;
/* Check if password exists */
if (!url.username)
return null;
@@ -62,11 +60,9 @@ function parseShareLink(uri, features) {
break;
case 'hysteria':
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
url = new URL('http://' + uri[1]);
params = url.searchParams;
/* WeChat-Video / FakeTCP are unsupported by sing-box currently */
if (!features.with_quic || (params.get('protocol') && params.get('protocol') !== 'udp'))
return null;
@@ -90,7 +86,6 @@ function parseShareLink(uri, features) {
break;
case 'hysteria2':
case 'hy2':
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
url = new URL('http://' + uri[1]);
params = url.searchParams;
@@ -114,7 +109,6 @@ function parseShareLink(uri, features) {
break;
case 'snell':
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
url = new URL('http://' + uri[1]);
params = url.searchParams;
@@ -153,7 +147,6 @@ function parseShareLink(uri, features) {
break;
case 'ss':
try {
/* "Lovely" Shadowrocket format */
try {
let suri = uri[1].split('#'), slabel = '';
if (suri.length <= 2) {
@@ -163,15 +156,12 @@ function parseShareLink(uri, features) {
}
} catch(e) { }
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
url = new URL('http://' + uri[1]);
let userinfo;
if (url.username && url.password) {
/* User info encoded with URIComponent */
userinfo = [url.username, decodeURIComponent(url.password)];
} else if (url.username) {
/* User info encoded with base64 */
userinfo = hp.decodeBase64Str(decodeURIComponent(url.username)).split(':');
if (userinfo.length > 1)
userinfo = [userinfo[0], userinfo.slice(1).join(':')]
@@ -184,9 +174,6 @@ function parseShareLink(uri, features) {
if (url.search && url.searchParams.get('plugin')) {
let plugin_info = url.searchParams.get('plugin').split(';');
plugin = plugin_info[0];
/* sing-box only knows obfs-local; simple-obfs (an old
alias providers still emit) would otherwise be rejected
as "plugin not found" and take the whole config with it */
if (plugin === 'simple-obfs')
plugin = 'obfs-local';
plugin_opts = (plugin_info.length > 1) ? plugin_info.slice(1).join(';') : null;
@@ -203,7 +190,6 @@ function parseShareLink(uri, features) {
shadowsocks_plugin_opts: plugin_opts
};
} catch(e) {
/* Legacy format https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
uri = uri[1].split('@');
if (uri.length < 2)
return null;
@@ -221,11 +207,9 @@ function parseShareLink(uri, features) {
break;
case 'trojan':
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
url = new URL('http://' + uri[1]);
params = url.searchParams;
/* Check if password exists */
if (!url.username)
return null;
@@ -256,11 +240,9 @@ function parseShareLink(uri, features) {
break;
case 'tuic':
/* https://github.com/daeuniverse/dae/discussions/182 */
url = new URL('http://' + uri[1]);
params = url.searchParams;
/* Check if uuid exists */
if (!url.username)
return null;
@@ -280,16 +262,13 @@ function parseShareLink(uri, features) {
break;
case 'vless':
/* https://github.com/XTLS/Xray-core/discussions/716 */
url = new URL('http://' + uri[1]);
params = url.searchParams;
/* Unsupported protocol */
if (params.get('type') === 'kcp')
return null;
else if (params.get('type') === 'quic' && ((params.get('quicSecurity') && params.get('quicSecurity') !== 'none') || !features.with_quic))
return null;
/* Check if uuid and type exist */
if (!url.username || !params.get('type'))
return null;
@@ -307,9 +286,6 @@ function parseShareLink(uri, features) {
tls_reality_public_key: params.get('pbk') ? decodeURIComponent(params.get('pbk')) : null,
tls_reality_short_id: params.get('sid'),
tls_utls: features.with_utls ? params.get('fp') : null,
/* only the value sing-box supports: any other flow is
rejected with "unsupported flow" and one bad node breaks
the whole generated config */
vless_flow: (['tls', 'reality'].includes(params.get('security')) && params.get('flow') === 'xtls-rprx-vision') ? params.get('flow') : null
};
switch (params.get('type')) {
@@ -340,26 +316,17 @@ function parseShareLink(uri, features) {
break;
case 'vmess':
/* "Lovely" shadowrocket format; uri is the split scheme/rest
array, so this has to test the link body (testing the array
itself compared '&' against its elements and never matched) */
if (uri[1].includes('&'))
return null;
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
uri = JSON.parse(hp.decodeBase64Str(uri[1]));
if (uri.v != '2')
return null;
/* Unsupported protocols */
else if (uri.net === 'kcp')
return null;
else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || !features.with_quic))
return null;
/* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (uri.aid && parseInt(uri.aid) !== 0)
* return null;
*/
config = {
label: uri.ps,
@@ -524,7 +491,6 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
}
o.modalonly = true;
/* Direct config */
o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'),
_('Write proxy protocol in the connection header.'));
o.value('', _('Disable'));
@@ -533,7 +499,6 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.depends('type', 'direct');
o.modalonly = true;
/* AnyTLS config start */
o = s.option(form.Value, 'anytls_idle_session_check_interval', _('Idle session check interval'),
_('Interval checking for idle sessions, in seconds.'));
o.datatype = 'uinteger';
@@ -554,9 +519,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.placeholder = '0';
o.depends('type', 'anytls');
o.modalonly = true;
/* AnyTLS config end */
/* Hysteria (2) config start */
o = s.option(form.DynamicList, 'hysteria_hopping_port', _('Hopping port'));
o.depends('type', 'hysteria');
o.depends('type', 'hysteria2');
@@ -580,10 +543,6 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
o.value('udp');
/* WeChat-Video / FakeTCP are unsupported by sing-box currently
* o.value('wechat-video');
* o.value('faketcp');
*/
o.default = 'udp';
o.depends('type', 'hysteria');
o.rmempty = false;
@@ -655,13 +614,10 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
_('Disable Chrome QUIC handshake parroting, which is enabled by default since sing-box 1.14. Turn this on only when the server uses an Ed25519 certificate or the handshake otherwise fails.'));
o.depends('type', 'hysteria2');
o.modalonly = true;
/* Hysteria (2) config end */
/* Shadowsocks config start */
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
for (let i of hp.shadowsocks_encrypt_methods)
o.value(i);
/* Stream ciphers */
o.value('aes-128-ctr');
o.value('aes-192-ctr');
o.value('aes-256-ctr');
@@ -687,9 +643,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.depends('shadowsocks_plugin', 'obfs-local');
o.depends('shadowsocks_plugin', 'v2ray-plugin');
o.modalonly = true;
/* Shadowsocks config end */
/* ShadowTLS config */
o = s.option(form.ListValue, 'shadowtls_version', _('ShadowTLS version'));
o.value('1', _('v1'));
o.value('2', _('v2'));
@@ -699,7 +653,6 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.rmempty = false;
o.modalonly = true;
/* Socks config */
o = s.option(form.ListValue, 'socks_version', _('Socks version'));
o.value('4', _('Socks4'));
o.value('4a', _('Socks4A'));
@@ -709,7 +662,6 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.rmempty = false;
o.modalonly = true;
/* SSH config start */
o = s.option(form.Value, 'ssh_client_version', _('Client version'),
_('Random version will be used if empty.'));
o.depends('type', 'ssh');
@@ -733,9 +685,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.password = true;
o.depends('type', 'ssh');
o.modalonly = true;
/* SSH config end */
/* TUIC config start */
o = s.option(form.Value, 'uuid', _('UUID'));
o.password = true;
o.depends('type', 'tuic');
@@ -779,9 +729,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.default = '10';
o.depends('type', 'tuic');
o.modalonly = true;
/* Tuic config end */
/* VMess / VLESS config start */
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
o.value('', _('None'));
o.value('xtls-rprx-vision');
@@ -816,13 +764,9 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
_('Protocol parameter. Enable length block encryption.'));
o.depends('type', 'vmess');
o.modalonly = true;
/* VMess config end */
/* Transport config start */
hp.renderTransportOptions(s, { features: features, side: 'client' });
/* Transport config end */
/* Wireguard config start */
o = s.option(form.DynamicList, 'wireguard_local_address', _('Local address'),
_('List of IP (v4 or v6) addresses prefixes to be assigned to the interface.'));
o.datatype = 'cidr';
@@ -868,9 +812,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.datatype = 'uinteger';
o.depends('type', 'wireguard');
o.modalonly = true;
/* Wireguard config end */
/* Mux config start */
o = s.option(form.Flag, 'multiplex', _('Multiplex'));
o.depends('type', 'shadowsocks');
o.depends('type', 'trojan');
@@ -927,9 +869,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.datatype = 'uinteger';
o.depends('multiplex_brutal', '1');
o.modalonly = true;
/* Mux config end */
/* Snell config start */
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
_('sing-box implements Snell v4/v5 wire as v4 and v6. The pre-shared key (Password above) must be 12-255 bytes for v6.'));
o.value('4', _('v4'));
@@ -967,9 +907,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.value('unsafe-raw', _('unsafe-raw'));
o.depends({'type': 'snell', 'snell_version': '6'});
o.modalonly = true;
/* Snell config end */
/* TLS config start */
hp.renderTlsOptions(s, {
side: 'client',
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'shadowtls', 'trojan', 'tuic', 'vless', 'vmess' ],
@@ -1063,9 +1001,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.depends('tls_reality', '1');
o.modalonly = true;
}
/* TLS config end */
/* Extra settings start */
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'));
o.modalonly = true;
@@ -1088,7 +1024,6 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.default = '2';
o.depends('udp_over_tcp', '1');
o.modalonly = true;
/* Extra settings end */
return s;
}
@@ -1107,7 +1042,6 @@ return view.extend({
let routing_mode = uci.get(data[0], 'config', 'routing_mode');
let features = data[1];
/* Cache subscription information, it will be called multiple times */
let subinfo = [];
for (let suburl of (uci.get(data[0], 'subscription', 'subscription_url') || [])) {
const url = new URL(suburl);
@@ -1120,8 +1054,6 @@ return view.extend({
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
/* Node settings start */
/* User nodes start */
s.tab('node', _('Nodes'));
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
@@ -1133,8 +1065,6 @@ return view.extend({
return true;
}
/* Import subscription links start */
/* Thanks to luci-app-shadowsocks-libev */
ss.handleLinkImport = function() {
let textarea = new ui.Textarea();
ui.showModal(_('Import share links'), [
@@ -1151,7 +1081,6 @@ return view.extend({
click: ui.createHandlerFn(this, () => {
let input_links = textarea.getValue().trim().split('\n');
if (input_links && input_links[0]) {
/* Remove duplicate lines */
input_links = input_links.reduce((pre, cur) =>
(!pre.includes(cur) && pre.push(cur), pre), []);
@@ -1163,9 +1092,6 @@ return view.extend({
try {
config = parseShareLink(l, features);
} catch (e) {
/* a single unparsable link must not abort
the whole import (the changes made so
far were left unsaved before) */
config = null;
}
if (config) {
@@ -1177,10 +1103,6 @@ return view.extend({
let nameHash = hp.calcStringMD5(config.label);
let previous = uci.get(data[0], nameHash) || {};
let sid = uci.add(data[0], 'node', nameHash);
/* drop options the node no longer has:
uci.set() only writes the keys below,
so re-importing a changed protocol
used to leave the old fields behind */
Object.keys(previous).forEach((k) => {
if (k.charAt(0) !== '.' && !(k in config))
uci.unset(data[0], sid, k);
@@ -1239,10 +1161,7 @@ return view.extend({
return el;
}
/* Import subscription links end */
/* User nodes end */
/* Subscription nodes start */
for (const info of subinfo) {
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
@@ -1251,10 +1170,7 @@ return view.extend({
return (uci.get(data[0], section_id, 'grouphash') === info.hash);
}
}
/* Subscription nodes end */
/* Node settings end */
/* Subscriptions settings start */
s.tab('subscription', _('Subscriptions'));
o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'),
@@ -1383,7 +1299,6 @@ return view.extend({
return this.map.save(null, true);
}
/* Subscriptions settings end */
return m.render();
}
@@ -104,7 +104,6 @@ function handleGenKey(option) {
password = hp.generateRand('hex', 16);
break;
}
/* AEAD */
((length) => {
if (length && length > 0)
password = hp.generateRand('base64', length);
@@ -242,19 +241,13 @@ return view.extend({
}
o.modalonly = true;
/* AnyTLS config */
o = s.option(form.DynamicList, 'anytls_padding_scheme', _('Padding scheme'),
_('AnyTLS padding scheme in array.'));
o.depends('type', 'anytls');
o.modalonly = true;
/* Hysteria (2) config start */
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
o.value('udp');
/* WeChat-Video / FakeTCP are unsupported by sing-box currently
o.value('wechat-video');
o.value('faketcp');
*/
o.default = 'udp';
o.depends('type', 'hysteria');
o.rmempty = false;
@@ -323,9 +316,7 @@ return view.extend({
_('HTTP3 server behavior when authentication fails.<br/>A 404 page will be returned if empty.'));
o.depends('type', 'hysteria2');
o.modalonly = true;
/* Hysteria (2) config end */
/* Snell config start */
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
_('The pre-shared key (password) above must be 12-255 bytes for v6.'));
o.value('5', _('v5'));
@@ -348,9 +339,7 @@ return view.extend({
o.value('unsafe-raw', _('unsafe-raw'));
o.depends({'type': 'snell', 'snell_version': '6'});
o.modalonly = true;
/* Snell config end */
/* Shadowsocks config */
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
for (let i of hp.shadowsocks_encrypt_methods)
o.value(i);
@@ -358,7 +347,6 @@ return view.extend({
o.depends('type', 'shadowsocks');
o.modalonly = true;
/* Tuic config start */
o = s.option(CBIGenValue, 'uuid', _('UUID'));
o.password = true;
o.depends('type', 'tuic');
@@ -395,9 +383,7 @@ return view.extend({
o.default = '10';
o.depends('type', 'tuic');
o.modalonly = true;
/* Tuic config end */
/* VLESS / VMess config start */
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
o.value('', _('None'));
o.value('xtls-rprx-vision');
@@ -409,13 +395,9 @@ return view.extend({
o.datatype = 'uinteger';
o.depends('type', 'vmess');
o.modalonly = true;
/* VMess config end */
/* Transport config start */
hp.renderTransportOptions(s, { features: features, side: 'server' });
/* Transport config end */
/* Mux config start */
o = s.option(form.Flag, 'multiplex', _('Multiplex'));
o.depends('type', 'shadowsocks');
o.depends('type', 'trojan');
@@ -445,9 +427,7 @@ return view.extend({
o.depends('multiplex_brutal', '1');
o.modalonly = true;
}
/* Mux config end */
/* TLS config start */
hp.renderTlsOptions(s, {
side: 'server',
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'trojan', 'tuic', 'vless', 'vmess' ],
@@ -725,9 +705,7 @@ return view.extend({
o.rows = 3;
o.depends('tls', '1');
o.modalonly = true;
/* TLS config end */
/* Extra settings start */
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'),
_('Enable tcp fast open for listener.'));
o.depends({'network': 'udp', '!reverse': true});
@@ -766,7 +744,6 @@ return view.extend({
o = s.option(form.Flag, 'reuse_addr', _('Reuse address'),
_('Reuse listener address.'));
o.modalonly = true;
/* Extra settings end */
return m.render();
}
@@ -14,13 +14,10 @@
'require ui';
'require view';
/* Thanks to luci-app-aria2 */
const css = ' \
#log_textarea { \
padding: 10px; \
text-align: left; \
} \
#log_textarea pre { \
padding: .5rem; \
word-break: break-all; \
margin: 0; \
@@ -257,7 +254,6 @@ return view.extend({
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
o.rawhtml = true;
/* local rule-sets (used by bypass_mainland_china when present) */
o = s.option(form.DummyValue, '_geoip_cn_version', _('GeoIP CN rule-set version'));
o.cfgvalue = L.bind(getResVersion, this, o, 'geoip_cn');
o.rawhtml = true;
@@ -21,8 +21,6 @@ if (match(proxy_mode, /tun/)) {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node !== 'nil') {
/* the name is interpolated into an nftables rule: keep it to what an
interface name can actually be */
const raw_tun = trim(uci.get(cfgname, 'infra', 'tun_name') ?? '');
tun_name = match(raw_tun, /^[A-Za-z0-9._-]{1,15}$/) ? raw_tun : 'singtun0';
}
@@ -1,21 +1,6 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Single source of truth for every fw4 chain and set owned by homeproxy.
#
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
# decides which of them actually exist for the current proxy mode.
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
#
# When you add or remove a chain/set in firewall_post.ut, update this list in
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
# fails when they drift.
#
# Each object is cleaned up independently: the cleanup loop deletes one at a
# time so that a chain/set which does not exist in the current mode cannot
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
# every preceding command in that case.
HP_FW4_CHAINS="
homeproxy_output_redir
@@ -20,9 +20,7 @@ import {
const ubus = connect();
/* const features = ubus.call('luci.homeproxy', 'singbox_get_features') || {}; */
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
@@ -45,17 +43,6 @@ const uciruleset = 'ruleset';
const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainland_china';
/*
* Single source of truth for "which config branch are we generating".
*
* The three generation chains below (DNS, outbounds, route) must be selected by
* the routing mode, NOT by whether main_node / default_outbound happen to be
* set. Those two answer a different question: in custom mode default_outbound
* is legitimately 'nil' (every routing rule picks its own outbound, there is no
* default) and main_node is never read. Deriving the mode from them made a
* perfectly normal custom config fall through every branch and emit a config
* containing none of the user's DNS servers, nodes, rules or rule-sets.
*/
const is_custom_mode = (routing_mode === 'custom');
let wan_dns = ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0];
@@ -96,14 +83,12 @@ if (routing_mode !== 'custom') {
proxy_domain_list = proxy_list_raw ? split(trim(proxy_list_raw), /[\r\n]/) : [];
} else {
/* DNS settings */
dns_default_strategy = uci.get(uciconfig, ucidnssetting, 'default_strategy');
dns_default_server = uci.get(uciconfig, ucidnssetting, 'default_server');
dns_disable_cache = uci.get(uciconfig, ucidnssetting, 'disable_cache');
dns_disable_cache_expire = uci.get(uciconfig, ucidnssetting, 'disable_cache_expire');
dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet');
/* Routing settings */
default_outbound = uci.get(uciconfig, uciroutingsetting, 'default_outbound') || 'nil';
default_outbound_dns = uci.get(uciconfig, uciroutingsetting, 'default_outbound_dns') || 'default-dns';
domain_strategy = uci.get(uciconfig, uciroutingsetting, 'domain_strategy');
@@ -148,7 +133,6 @@ if (match(proxy_mode, /tun/)) {
}
const log_level = uci.get(uciconfig, ucimain, 'log_level') || 'warn';
/* UCI config end */
const tun_dns_mode_raw = uci.get(uciconfig, ucimain, 'tun_dns_mode'),
tun_dns_address = uci.get(uciconfig, ucimain, 'tun_dns_address'),
@@ -160,13 +144,6 @@ const tun_dns_mode = (tun_dns_mode_raw === 'default') ? '' : tun_dns_mode_raw,
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
/* Config helper start */
/*
* Direct-node destination override, keyed by node section name. It must be
* declared before the helpers that touch it: ucode resolves let/const
* lexically and does not hoist them, so a function defined earlier would
* resolve the name as an undeclared global and throw under strict mode.
*/
const direct_overrides = {};
function parse_port(strport) {
@@ -256,28 +233,22 @@ function generate_outbound(node) {
server: node.address,
server_port: strToInt(node.port),
/* Hysteria(2) */
server_ports: node.hysteria_hopping_port,
username: (node.type !== 'ssh') ? node.username : null,
user: (node.type === 'ssh') ? node.username : null,
/* Snell authenticates with psk instead of password */
password: (node.type !== 'snell') ? node.password : null,
psk: (node.type === 'snell') ? node.password : null,
userkey: (node.type === 'snell') ? node.snell_userkey : null,
reuse: (node.type === 'snell') ? strToBool(node.snell_reuse) : null,
/* Snell v4: HTTP obfuscation; v6: traffic shaping mode */
obfs_mode: (node.type === 'snell') ? (node.snell_obfs_mode || null) : null,
obfs_host: (node.type === 'snell') ? (node.snell_obfs_host || null) : null,
mode: (node.type === 'snell') ? (node.snell_mode || null) : null,
/* Direct */
proxy_protocol: strToInt(node.proxy_protocol),
/* AnyTLS */
idle_session_check_interval: strToTime(node.anytls_idle_session_check_interval),
idle_session_timeout: strToTime(node.anytls_idle_session_timeout),
min_idle_session: strToInt(node.anytls_min_idle_session),
/* Hysteria (2) */
hop_interval: strToTime(node.hysteria_hop_interval),
hop_interval_max: strToTime(node.hysteria_hop_interval_max),
up_mbps: strToInt(node.hysteria_up_mbps),
@@ -290,29 +261,23 @@ function generate_outbound(node) {
} : node.hysteria_obfs_password,
auth: (node.hysteria_auth_type === 'base64') ? node.hysteria_auth_payload : null,
auth_str: (node.hysteria_auth_type === 'string') ? node.hysteria_auth_payload : null,
/* sing-box 1.14: Hysteria2 QUIC params (Hysteria v1 recv-window tuning removed upstream) */
bbr_profile: (node.type === 'hysteria2') ? (node.hysteria_bbr_profile || null) : null,
disable_chrome_parrot: (node.type === 'hysteria2' && node.hysteria_disable_chrome_parrot === '1') ? true : null,
/* Shadowsocks */
method: node.shadowsocks_encrypt_method,
plugin: node.shadowsocks_plugin,
plugin_opts: node.shadowsocks_plugin_opts,
/* ShadowTLS / Socks / Snell */
version: (node.type === 'shadowtls') ? strToInt(node.shadowtls_version) : ((node.type === 'socks') ? node.socks_version : ((node.type === 'snell') ? (strToInt(node.snell_version) || 4) : null)),
/* SSH */
client_version: node.ssh_client_version,
host_key: node.ssh_host_key,
host_key_algorithms: node.ssh_host_key_algo,
private_key: node.ssh_priv_key,
private_key_passphrase: node.ssh_priv_key_pp,
/* Tuic */
uuid: node.uuid,
congestion_control: node.tuic_congestion_control,
udp_relay_mode: node.tuic_udp_relay_mode,
udp_over_stream: strToBool(node.tuic_udp_over_stream),
zero_rtt_handshake: strToBool(node.tuic_enable_zero_rtt),
heartbeat: strToTime(node.tuic_heartbeat),
/* VLESS / VMess */
flow: node.vless_flow,
alter_id: strToInt(node.vmess_alterid),
security: node.vmess_encrypt,
@@ -344,8 +309,6 @@ function generate_outbound(node) {
udp_fragment: strToBool(node.udp_fragment)
};
/* Direct-node destination override: sing-box removed these options from
the direct outbound since 1.13; emit them via the route action instead */
if (node.type === 'direct' && (!isEmpty(node.override_address) || !isEmpty(node.override_port)))
direct_overrides[node['.name']] = {
override_address: node.override_address,
@@ -432,11 +395,6 @@ function isDirectOutboundTag(tag) {
return !isEmpty(node) && node.type === 'direct';
}
/*
* Remote rule-sets are downloaded through a top-level http_client in sing-box
* 1.14 (the per-rule-set download_detour was removed). One client is created
* per distinct detour and reused by every rule-set that dials through it.
*/
const http_clients = [];
const http_seen = {};
@@ -450,9 +408,6 @@ function http_client_for(detour) {
http_seen[detour] = true;
const client = { tag: tag };
/* sing-box 1.14 rejects an empty detour on the direct outbound (pure TUN
mode has no self_mark on it); omitting detour uses the same system
direct dialer, so behaviour is unchanged. */
if (!(isEmpty(self_mark) && isDirectOutboundTag(detour)))
client.detour = detour;
@@ -461,10 +416,6 @@ function http_client_for(detour) {
return tag;
}
/*
* sing-box requires dns.servers[].headers to be a map, while the LuCI field is
* a dynamic list of "Name: value" lines (and older configs may hold either).
*/
function parse_headers(headers) {
if (isEmpty(headers))
return null;
@@ -482,11 +433,9 @@ function parse_headers(headers) {
return isEmpty(out) ? null : out;
}
/* Config helper end */
const config = {};
/* Log */
config.log = {
disabled: false,
level: log_level,
@@ -494,7 +443,6 @@ config.log = {
timestamp: true
};
/* NTP */
if (!isEmpty(ntp_server))
config.ntp = {
enabled: true,
@@ -503,8 +451,6 @@ if (!isEmpty(ntp_server))
domain_resolver: 'default-dns',
};
/* DNS start */
/* Default settings */
config.dns = {
servers: [
{
@@ -532,7 +478,6 @@ config.dns = {
};
if (!isEmpty(main_node)) {
/* Main DNS */
push(config.dns.servers, {
tag: 'main-dns',
domain_resolver: {
@@ -551,8 +496,6 @@ if (!isEmpty(main_node)) {
server: (routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
});
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains.
Opt out with `block_https_rr 0` when clients should use HTTPS RR/ECH. */
if (uci.get(uciconfig, ucimain, 'block_https_rr') !== '0')
push(config.dns.rules, {
query_type: [64, 65],
@@ -564,18 +507,12 @@ if (!isEmpty(main_node)) {
tag: 'china-dns',
domain_resolver: {
server: 'default-dns',
/* follow the IPv6 setting like main-dns does, instead of
preferring AAAA even on IPv4-only setups */
strategy: (ipv6_support !== '1') ? 'ipv4_only' : null
},
detour: self_mark ? 'direct-out' : null,
...parse_dnsserver(china_dns_server)
});
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
default-dns directly: china-dns (223.5.5.5) has intermittent
multi-second first responses for NAPTR, causing 'context deadline
exceeded' (e.g. sipgz12.hbq.r.10086.cn) */
push(config.dns.rules, {
query_type: [35],
domain_suffix: ['r.10086.cn', '10086.cn', 'pub.3gppnetwork.org'],
@@ -596,7 +533,6 @@ if (!isEmpty(main_node)) {
server: 'china-dns'
});
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
if (uci.get(uciconfig, ucimain, 'cn_ip_fallback') === '1') {
push(config.dns.rules, {
action: 'evaluate',
@@ -612,7 +548,6 @@ if (!isEmpty(main_node)) {
}
}
} else if (is_custom_mode) {
/* DNS servers */
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
if (cfg.enabled !== '1')
return;
@@ -640,9 +575,6 @@ if (!isEmpty(main_node)) {
});
});
/* DNS rules */
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
evaluate action; deprecated strategy/accept_empty fields are dropped. */
const builtin_dns_rules = [];
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
if (cfg.enabled !== '1')
@@ -705,10 +637,6 @@ if (!isEmpty(main_node)) {
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
if (legacy_filter && !rule.match_response && cfg.action === 'route') {
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
paradigm. Carry the original query-matching fields onto the evaluate
prefix rule so only queries that would have hit this rule get
pre-resolved; an unconditional evaluate would resolve every query. */
const eval_tag = '_hp_eval_' + cfg['.name'];
const eval_rule = {
action: 'evaluate',
@@ -730,14 +658,11 @@ if (!isEmpty(main_node)) {
rule.match_response = eval_tag;
}
/* ip_cidr / ip_is_private are only valid with match_response in 1.14 */
if (rule.match_response) {
rule.ip_cidr = cfg.ip_cidr;
rule.ip_is_private = strToBool(cfg.ip_is_private);
}
/* sing-box rejects the whole config when a route/evaluate action has no
server, so drop such a rule with a reason instead. */
if ((rule.action === 'route' || rule.action === 'evaluate') && isEmpty(rule.server)) {
warn(sprintf("homeproxy: DNS rule '%s' has no server configured, skipping it.", cfg['.name']));
return;
@@ -749,19 +674,9 @@ if (!isEmpty(main_node)) {
config.dns.final = get_resolver(dns_default_server);
}
/* DNS end */
/* Inbound start */
config.inbounds = [];
/*
* dns-in is the local listener the DNS chain hands queries to: dnsmasq
* forwards to 127.0.0.1#<dns_port> and the nft DNS hijack redirects to
* dnsmasq, not here. It used to listen on '::', which published a
* proxy-backed resolver to every LAN client on the DNS port; loopback is the
* whole reachable surface it needs. mixed-in stays on '::' - that one is the
* SOCKS/HTTP listener clients are meant to reach.
*/
push(config.inbounds, {
type: 'direct',
tag: 'dns-in',
@@ -817,12 +732,9 @@ if (match(proxy_mode, /tun/))
udp_nat_max: udp_nat_max,
stack: tcpip_stack,
});
/* Inbound end */
/* Outbound start */
config.endpoints = [];
/* Default outbounds */
config.outbounds = [
{
type: 'direct',
@@ -835,7 +747,6 @@ config.outbounds = [
}
];
/* Main outbounds */
if (!isEmpty(main_node)) {
let urltest_nodes = [];
@@ -958,16 +869,7 @@ if (!isEmpty(main_node)) {
if (isEmpty(config.endpoints))
config.endpoints = null;
/* Outbound end */
/* Routing rules start */
/* Default settings */
/*
* The sniff rule below deliberately carries no `timeout`. Reports from
* sing-box 1.14.2 show it rejected with "route.rules[1].timeout: json: unknown
* field". The official builds accept it, so the exact trigger is still open,
* but the default is already 300ms and omitting the field is behaviour-neutral.
*/
config.route = {
rules: [
{
@@ -983,19 +885,12 @@ config.route = {
default_interface: default_interface
};
/* Routing rules */
if (!isEmpty(main_node)) {
/* Resolve outbound server domains through the WAN default resolver.
Do not use china-dns here: china-dns is for resolving mainland China
destinations, not the proxy node itself. Coupling node bootstrap to
china_dns_server can break dialing when that resolver is polluted or
unsuitable for the node domain. */
config.route.default_domain_resolver = {
server: 'default-dns',
strategy: (ipv6_support !== '1') ? 'prefer_ipv4' : null
};
/* Direct list */
if (length(direct_domain_list))
push(config.route.rules, {
rule_set: 'direct-domain',
@@ -1003,14 +898,17 @@ if (!isEmpty(main_node)) {
outbound: 'direct-out'
});
/* Bypass CN traffic: resolve the destination first, then route by IP.
sing-box does not match an IP-based rule set (geoip-cn) against a domain
destination unless it is resolved first, so add an explicit resolve
action; geoip-cn then sends China IPs to direct and everything else falls
through to main-out (proxy). This avoids relying on the geosite-* domain
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
beacons) as "cn" and send them direct to time out. Keep the direct-domain
fast-path above for known direct domains. */
if (dedicated_udp_node) {
const udp_override = direct_overrides[main_udp_node] || null;
push(config.route.rules, {
network: 'udp',
action: 'route',
outbound: 'main-udp-out',
override_address: udp_override ? udp_override.override_address : null,
override_port: udp_override ? udp_override.override_port : null
});
}
if (routing_mode === 'bypass_mainland_china') {
push(config.route.rules, {
action: 'resolve',
@@ -1023,20 +921,6 @@ if (!isEmpty(main_node)) {
});
}
/* Main UDP out */
if (dedicated_udp_node) {
const udp_override = direct_overrides[main_udp_node] || null;
push(config.route.rules, {
network: 'udp',
action: 'route',
outbound: 'main-udp-out',
override_address: udp_override ? udp_override.override_address : null,
override_port: udp_override ? udp_override.override_port : null
});
}
/* Direct-node destination override, emitted as route-options action
(direct outbound options removed since sing-box 1.13) */
const main_override = direct_overrides[main_node] || null;
if (main_override)
push(config.route.rules, {
@@ -1047,8 +931,6 @@ if (!isEmpty(main_node)) {
config.route.final = 'main-out';
/* Rule set */
/* Direct list */
if (length(direct_domain_list))
push(config.route.rule_set, {
type: 'inline',
@@ -1060,7 +942,6 @@ if (!isEmpty(main_node)) {
]
});
/* Proxy list */
if (length(proxy_domain_list))
push(config.route.rule_set, {
type: 'inline',
@@ -1073,17 +954,6 @@ if (!isEmpty(main_node)) {
});
if (routing_mode === 'bypass_mainland_china') {
/*
* The two rule-sets sing-box actually needs for this mode. Each one is
* used from the local .srs file when update_resources.sh has fetched
* it, and only falls back to a remote fetch otherwise: a remote
* rule-set is initialized at every start (sing-box fails to start when
* the fetch fails even if nothing references it), which would make a
* cold boot depend on reaching the CDN through the node. The fallback
* keeps a fresh install working before the first resource update.
* geosite-noncn is intentionally not declared: no rule references it,
* and sing-box downloads it anyway.
*/
const preset_rulesets = [
{
tag: 'geoip-cn',
@@ -1147,11 +1017,6 @@ if (!isEmpty(main_node)) {
rule_override_port = rule_direct_override.override_port;
}
/* sing-box routing rule: emit match fields plus action-specific fields.
client is the sniffed client type (only set with protocol quic/ssh);
resolve adds server/strategy/disable_cache/rewrite_ttl/client_subnet;
reject adds method/no_drop. Emitted conditionally so a given action
never carries fields sing-box rejects for it. */
const rule = {
ip_version: strToInt(cfg.ip_version),
protocol: cfg.protocol,
@@ -1207,8 +1072,6 @@ if (!isEmpty(main_node)) {
push(config.route.rules, rule);
});
/* Direct-node destination override, emitted as route-options action
(direct outbound options removed since sing-box 1.13) */
const final_override = get_direct_override(default_outbound);
if (final_override)
push(config.route.rules, {
@@ -1219,7 +1082,6 @@ if (!isEmpty(main_node)) {
config.route.final = get_outbound(default_outbound);
/* Rule set */
uci.foreach(uciconfig, uciruleset, (cfg) => {
if (cfg.enabled !== '1')
return null;
@@ -1230,8 +1092,6 @@ if (!isEmpty(main_node)) {
rs_tag = [rs_tag];
for (let t in extra_tags)
push(rs_tag, 'cfg-' + t + '-rule');
/* sing-box 1.14: multi-tag requires a {tag} placeholder in the fetch source
(remote: url and initial_path, local: path) */
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
if (!match(fetch_ref, /\{tag\}/))
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder.", cfg['.name'], cfg.type));
@@ -1247,8 +1107,6 @@ if (!isEmpty(main_node)) {
url: cfg.url,
update_interval: cfg.update_interval
};
/* sing-box 1.14 downloads remote rule-sets through a top-level
http_client; download_detour no longer exists. */
if (cfg.type === 'remote')
ruleset.http_client = http_client_for(get_outbound(cfg.outbound) || get_outbound(default_outbound));
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
@@ -1257,28 +1115,12 @@ if (!isEmpty(main_node)) {
});
}
/*
* Belt-and-braces for route.default_domain_resolver.
*
* sing-box 1.14 removed the legacy outbound DNS rules and refuses to start when
* a dial-fields user has a domain to resolve without a resolver. The exemption
* for "only one DNS server configured" never applies here: default-dns and
* system-dns are emitted unconditionally, so every config has at least two.
* That makes this a global requirement, not a per-mode one, so it must not
* depend on which branch above ran. The branches already pick the right
* resolver (the WAN resolver, or default_outbound_dns in custom mode); this
* only covers the degenerate "not custom mode and no main node" case.
*/
if (isEmpty(config.route.default_domain_resolver))
config.route.default_domain_resolver = { server: 'default-dns' };
/* Remote rule-sets download through the http_clients collected while the
rule-sets were built (see http_client_for). */
if (length(http_clients))
config.http_clients = http_clients;
/* Routing rules end */
/* Experimental start */
if (routing_mode in ['bypass_mainland_china', 'custom']) {
config.experimental = {
cache_file: {
@@ -1288,7 +1130,6 @@ if (routing_mode in ['bypass_mainland_china', 'custom']) {
}
};
}
/* Experimental end */
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
@@ -1300,7 +1141,4 @@ if (system('/usr/bin/sing-box check --config ' + client_tmp) !== 0) {
exit(1);
}
system('mv -f ' + client_tmp + ' ' + RUN_DIR + '/sing-box-c.json');
/* The generated config carries every node credential (passwords, UUIDs,
private keys) and writefile() has no mode argument, so it lands with the
process umask; chmod it to the sing-box user only. */
system('chmod 600 ' + RUN_DIR + '/sing-box-c.json');
@@ -15,7 +15,6 @@ import {
removeBlankAttrs, buildTLSObject, buildTransportObject, HP_DIR, RUN_DIR
} from 'homeproxy';
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
@@ -24,11 +23,9 @@ uci.load(uciconfig);
const uciserver = 'server';
const log_level = uci.get(uciconfig, uciserver, 'log_level') || 'warn';
/* UCI config end */
const config = {};
/* Log */
config.log = {
disabled: false,
level: log_level,
@@ -42,7 +39,6 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
if (cfg.enabled !== '1')
return;
/* Snell (1.14): single-psk inbound; shape differs from the generic users[] block below */
if (cfg.type === 'snell') {
push(config.inbounds, {
type: 'snell',
@@ -76,10 +72,8 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
udp_timeout: strToTime(cfg.udp_timeout),
network: cfg.network,
/* AnyTLS */
padding_scheme: cfg.anytls_padding_scheme,
/* Hysteria (2) */
up_mbps: strToInt(cfg.hysteria_up_mbps),
down_mbps: strToInt(cfg.hysteria_down_mbps),
obfs: cfg.hysteria_obfs_type ? {
@@ -91,31 +85,25 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
ignore_client_bandwidth: strToBool(cfg.hysteria_ignore_client_bandwidth),
masquerade: cfg.hysteria_masquerade,
/* Shadowsocks */
method: (cfg.type === 'shadowsocks') ? cfg.shadowsocks_encrypt_method : null,
password: (cfg.type in ['shadowsocks', 'shadowtls']) ? cfg.password : null,
/* Tuic */
congestion_control: cfg.tuic_congestion_control,
auth_timeout: strToTime(cfg.tuic_auth_timeout),
zero_rtt_handshake: strToBool(cfg.tuic_enable_zero_rtt),
heartbeat: strToTime(cfg.tuic_heartbeat),
/* AnyTLS / HTTP / Hysteria (2) / Mixed / Socks / Trojan / Tuic / VLESS / VMess */
users: (cfg.type !== 'shadowsocks') ? [
{
name: !(cfg.type in ['http', 'mixed', 'naive', 'socks']) ? 'cfg-' + cfg['.name'] + '-server' : null,
username: cfg.username,
password: cfg.password,
/* Hysteria */
auth: (cfg.hysteria_auth_type === 'base64') ? cfg.hysteria_auth_payload : null,
auth_str: (cfg.hysteria_auth_type === 'string') ? cfg.hysteria_auth_payload : null,
/* Tuic */
uuid: cfg.uuid,
/* VLESS / VMess */
flow: cfg.vless_flow,
alterId: strToInt(cfg.vmess_alterid)
}
@@ -150,5 +138,4 @@ if (system('/usr/bin/sing-box check --config ' + server_tmp) !== 0) {
exit(1);
}
system('mv -f ' + server_tmp + ' ' + RUN_DIR + '/sing-box-s.json');
/* keep the config (key paths, user credentials) out of world-readable mode */
system('chmod 600 ' + RUN_DIR + '/sing-box-s.json');
@@ -7,13 +7,9 @@
import { mkstemp } from 'fs';
import { urldecode_params } from 'luci.http';
/* Global variables start */
export const HP_DIR = '/etc/homeproxy';
export const RUN_DIR = '/var/run/homeproxy';
/* Global variables end */
/* Utilities start */
/* Kanged from luci-app-commands */
export function shellQuote(s) {
return `'${replace(s, "'", "'\\''")}'`;
};
@@ -26,26 +22,15 @@ export function isBinary(str) {
return false;
};
/* Close a file descriptor, swallowing secondary errors (e.g. an already
closed fd). ucode has no `finally` clause, so both the normal and the
exceptional path of executeCommand() have to close explicitly. */
function closeFD(fd) {
if (fd) {
try {
fd.close();
} catch (e) {
/* already closed */
}
}
};
/*
* Read a descriptor to EOF. A single read(size) only returns up to `size`
* bytes, so the previous one-shot read(512 KiB) silently truncated larger
* responses - a subscription list above that size was cut mid-base64/mid-JSON
* and only showed up as a parse failure. MAX_OUTPUT bounds what an oversized
* or endless response can allocate.
*/
const MAX_OUTPUT = 16 * 1024 * 1024;
function readAll(fd) {
@@ -79,7 +64,6 @@ export function executeCommand(...args) {
const binary = isBinary(stdout);
/* mkstemp() returns delete-on-close files, so closing is enough */
closeFD(outfd);
closeFD(errfd);
@@ -91,8 +75,6 @@ export function executeCommand(...args) {
binary
};
} catch (e) {
/* Never leak the temporary descriptors on a failing run. ucode has
no `finally` clause, so the exception is re-raised by hand. */
closeFD(outfd);
closeFD(errfd);
@@ -114,19 +96,6 @@ export function getTime(epoch) {
};
/*
* GNU wget and OpenWrt's default /usr/bin/wget do not accept the same flags.
* On OpenWrt/ImmortalWrt that path comes from the uclient-fetch package
* (`ALTERNATIVES:=200:/usr/bin/wget:/bin/uclient-fetch`, `PROVIDES:=wget` in
* package/libs/uclient) and busybox ships no wget applet, so the effective
* wget is uclient-fetch unless wget-ssl happens to be installed on top of it.
* GNU-only flags break the fetch outright: uclient-fetch's getopt rejects
* `-nv` with "unrecognized option: n" before a single request is sent.
*
* The probe below tells the two apart. `-nv` (--no-verbose) is how GNU wget
* mutes the progress meter while still printing the failure reason on stderr;
* `-q` is the only meter switch uclient-fetch and busybox wget share.
*/
export function classifyWgetFlavor(exitcode, stdout) {
if (exitcode !== 0 || !stdout)
return 'compat';
@@ -146,13 +115,6 @@ export function wgetFlavor() {
return wget_flavor;
};
/*
* Fetch a URL and report both the body and, on failure, the reason. The
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
* caller can tell a DNS failure from a timeout or a TLS handshake error.
* Note that uclient-fetch's -q hides most failure messages, so on such a
* target the reason is often empty and only the exit code is left.
*/
export function wGETVerbose(url, ua) {
if (!url || type(url) !== 'string')
return { content: null, error: 'invalid URL' };
@@ -160,9 +122,6 @@ export function wGETVerbose(url, ua) {
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
/* -U/-T are understood by GNU wget, uclient-fetch and busybox wget alike;
* the long forms --user-agent/--timeout are not, busybox wget in
* particular knows neither of them. */
const output = executeCommand(`/usr/bin/wget ${wgetFlavor() === 'gnu' ? '-nv' : '-q'} -O- -U ${shellQuote(ua)} -T 10 ${shellQuote(url)}`) || {};
if (output.exitcode !== 0) {
let reason = trim(output.stderr || '');
@@ -180,9 +139,7 @@ export function wGETVerbose(url, ua) {
export function wGET(url, ua) {
return wGETVerbose(url, ua).content;
};
/* Utilities end */
/* String helper start */
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
@@ -199,16 +156,9 @@ export function strToTime(str) {
if (isEmpty(str))
return null;
/* Preserve values that already carry a time unit (e.g. "30s", "1m") */
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
};
/*
* Milliseconds variant of strToTime(). Used by options whose LuCI field is
* documented and validated in milliseconds while sing-box expects a duration
* (e.g. route rule tls_fragment_fallback_delay, whose default is 500ms);
* strToTime() used to turn "500" into "500s".
*/
export function strToMs(str) {
if (isEmpty(str))
return null;
@@ -255,20 +205,14 @@ export function validation(datatype, data) {
return (ret === 0);
};
/* Validate IP/CIDR format to prevent nftables template injection from resource files */
export function isValidCIDR(addr, family) {
if (isEmpty(addr))
return false;
/* Strip leading/trailing whitespace */
addr = trim(addr);
if (!addr)
return false;
/* Split address and optional prefix. The prefix has to be validated as
strictly as the address: `int()` accepts a leading number ("1.2.3.4/0 }
..." parses as 0), which would let a poisoned resource file inject
statements into the nftables template this feeds. */
const parts = split(addr, '/');
const ip = parts[0];
const prefix = parts[1];
@@ -278,31 +222,24 @@ export function isValidCIDR(addr, family) {
if (length(parts) === 2 && !match(prefix, /^\d{1,3}$/))
return false;
/* Validate IP part */
if (family === 4) {
if (!match(ip, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/))
return false;
/* Validate octet ranges */
const octets = split(ip, '.');
for (let o in octets)
if (int(o) > 255)
return false;
/* Validate prefix if present */
if (prefix && int(prefix) > 32)
return false;
} else if (family === 6) {
/* Only hex digits and colons */
if (!match(ip, /^[0-9a-fA-F:]+$/))
return false;
/* At most one "::" */
if (match(ip, /::.*::/))
return false;
/* Validate group structure */
const dcolon = index(ip, '::');
if (dcolon === -1) {
/* Uncompressed form: exactly 8 groups of 1-4 hex digits */
const groups = split(ip, ':');
if (length(groups) !== 8)
return false;
@@ -310,7 +247,6 @@ export function isValidCIDR(addr, family) {
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
return false;
} else {
/* Compressed form: "::" expands to at least one zero group */
let count = 0;
for (let part in [substr(ip, 0, dcolon), substr(ip, dcolon + 2)]) {
if (part === '')
@@ -333,9 +269,7 @@ export function isValidCIDR(addr, family) {
return true;
};
/* String helper end */
/* String parser start */
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
@@ -351,14 +285,6 @@ export function decodeBase64Str(str) {
return b64dec(str);
};
/*
* Decode %XX escapes without touching '+'. urldecode() maps '+' to a space,
* which corrupts base64 userinfo ("+" is part of the alphabet), so share-link
* userinfo is percent-decoded with this instead.
*/
/*
* ucode has no tolower(), so both hex cases are mapped by hand.
*/
function hexValue(ch) {
const lower = index('0123456789abcdef', ch);
if (lower >= 0)
@@ -369,10 +295,6 @@ function hexValue(ch) {
return upper < 0 ? -1 : 10 + upper;
}
/*
* ucode's regex engine rejects \x00 inside a character class ("Missing ']'"),
* so control bytes are checked by hand.
*/
function hasControlChar(str) {
for (let i = 0; i < length(str); i++) {
const code = ord(str, i);
@@ -449,9 +371,6 @@ export function parseURL(url) {
return null;
if (objurl.userinfo) {
/* Percent-decode first: providers emit base64 userinfo with the
padding escaped ("=" as "%3D"), which used to decode to nothing
and silently dropped method/password. */
let userinfo = percentDecode(objurl.userinfo);
userinfo = replace(userinfo, /:(.+)$/, (_, val) => {
@@ -459,14 +378,6 @@ export function parseURL(url) {
return '';
});
/*
* Keep anything without whitespace or control characters: real-world
* userinfo carries base64 (with '=' or '+'), percent-encoded tokens
* and passwords with punctuation. The previous class was written as
* [A-Za-z0-9\+\-\_\.] but ucode's regex reads \+-\_ as the range
* 0x2B-0x5F, so it accepted '=@:;[]\^<>,/' and rejected '!' - it
* neither did what it documented nor covered real passwords.
*/
if (!match(userinfo, /\s/) && !hasControlChar(userinfo))
objurl.username = userinfo;
else
@@ -483,18 +394,7 @@ export function parseURL(url) {
return objurl;
};
/* String parser end */
/* Config generator helper start */
/*
* Shared sing-box TLS object builder for the client outbound and the server
* inbound. The property order below is significant: the generated JSON keeps
* the insertion order of this object after removeBlankAttrs() drops the blank
* attributes, and both generators used to emit the fields in exactly this
* order. Keep client-only fields (insecure/handshake_timeout/utls) and
* server-only fields (key_path/certificate_provider) at their current
* positions when editing.
*/
export function buildTLSObject(cfg, is_server) {
if (cfg.tls !== '1')
return null;
@@ -540,7 +440,6 @@ export function buildTLSObject(cfg, is_server) {
ech: is_server ? (cfg.tls_ech_key ? {
enabled: true,
key: split(cfg.tls_ech_key, '\n')
/* config: split(cfg.tls_ech_config, '\n') */
} : null) : ((cfg.tls_ech === '1') ? {
enabled: true,
config: cfg.tls_ech_config,
@@ -567,8 +466,6 @@ export function buildTLSObject(cfg, is_server) {
};
};
/* Shared sing-box transport object builder; the client transport additionally
supports the gRPC keepalive hint, the server one does not. */
export function buildTransportObject(cfg, is_server) {
if (isEmpty(cfg.transport))
return null;
@@ -589,15 +486,7 @@ export function buildTransportObject(cfg, is_server) {
permit_without_stream: is_server ? null : strToBool(cfg.grpc_permit_without_stream)
};
};
/* Config generator helper end */
/* PEM validation start */
/*
* Check that `content` is a PEM certificate (is_private_key = false) or an
* RSA/EC private key (is_private_key = true): matching BEGIN/END boundaries
* with a base64 body in between. Kanged from luci-proto-openconnect; used by
* the rpcd certificate upload and available for future certificate features.
*/
export function isValidPEM(content, is_private_key) {
if (isEmpty(content))
return false;
@@ -619,4 +508,3 @@ export function isValidPEM(content, is_private_key) {
return true;
};
/* PEM validation end */
@@ -27,11 +27,9 @@ const uciinfra = 'infra',
uciroutingrule = 'routing_rule',
uciserver = 'server';
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
/* chinadns server now only accepts single server */
const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
@@ -42,51 +40,41 @@ if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
}
/* github_token option has been moved to config section */
const github_token = uci.get(uciconfig, uciinfra, 'github_token');
if (github_token) {
uci.set(uciconfig, ucimain, 'github_token', github_token);
uci.delete(uciconfig, uciinfra, 'github_token')
}
/* ntp_server was introduced */
if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
uci.set(uciconfig, uciinfra, 'ntp_server', 'nil');
/* tun_gso was deprecated in sb 1.11 */
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
uci.delete(uciconfig, uciinfra, 'tun_gso');
/* create migration section */
if (!uci.get(uciconfig, ucimigration))
uci.set(uciconfig, ucimigration, uciconfig);
/* delete old crontab command */
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
if (!migration_crontab) {
system('sed -i "/update_crond.sh/d" "/etc/crontabs/root" 2>/dev/null');
uci.set(uciconfig, ucimigration, 'crontab', '1');
}
/* log_level was introduced */
if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
uci.set(uciconfig, ucimain, 'log_level', 'warn');
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
/* experimental section was removed */
if (uci.get(uciconfig, 'experimental'))
uci.delete(uciconfig, 'experimental');
/* block-dns was removed from built-in dns servers */
const default_dns_server = uci.get(uciconfig, ucidns, 'default_server');
if (default_dns_server === 'block-dns') {
/* append a rule at last to block all DNS queries */
uci.set(uciconfig, '_migration_dns_final_block', ucidnsrule);
uci.set(uciconfig, '_migration_dns_final_block', 'label', 'migration_final_block_dns');
uci.set(uciconfig, '_migration_dns_final_block', 'enabled', '1');
@@ -98,12 +86,9 @@ else if (default_dns_server === 'local-dns')
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
const dns_server_migration = {};
/* DNS servers options */
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
/* legacy format was deprecated in sb 1.12 */
if (cfg.address) {
const addr = parseURL((!match(cfg.address, /:\/\//) ? 'udp://' : '') + (validation('ip6addr', cfg.address) ? `[${cfg.address}]` : cfg.address));
/* RCode was moved into DNS rules */
if (addr.protocol === 'rcode') {
dns_server_migration[cfg['.name']] = { action: 'predefined' };
switch (addr.hostname) {
@@ -156,9 +141,7 @@ uci.foreach(uciconfig, ucidnsserver, (cfg) => {
}
});
/* DNS rules options */
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
/* outbound was removed in sb 1.12 */
if (cfg.outbound) {
uci.delete(uciconfig, cfg['.name']);
if (!cfg.enabled)
@@ -181,20 +164,16 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
return;
}
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-dns was moved into action in sb 1.11 */
if (cfg.server === 'block-dns') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'server');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
/* strategy and client_subnet were moved into dns rules */
if (dns_server_migration[cfg.server]) {
if (dns_server_migration[cfg.server].strategy)
uci.set(uciconfig, cfg['.name'], 'strategy', dns_server_migration[cfg.server].strategy);
@@ -210,26 +189,20 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
}
});
/* nodes options */
uci.foreach(uciconfig, ucinode, (cfg) => {
/* tls_ech_tls_disable_drs is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_tls_disable_drs))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_tls_disable_drs');
/* tls_ech_enable_pqss is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_enable_pqss))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_enable_pqss');
/* wireguard_gso was deprecated in sb 1.11 */
if (!isEmpty(cfg.wireguard_gso))
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
/* hysteria_revc_window was a typo, renamed to hysteria_recv_window */
if (!isEmpty(cfg.hysteria_revc_window))
uci.rename(uciconfig, cfg['.name'], 'hysteria_revc_window', 'hysteria_recv_window');
});
/* sing-box 1.14: remove deprecated DNS semantics */
if (!isEmpty(uci.get(uciconfig, ucidns, 'independent_cache')))
uci.delete(uciconfig, ucidns, 'independent_cache');
@@ -248,38 +221,29 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
uci.delete(uciconfig, cfg['.name'], 'rule_set_ip_cidr_accept_empty');
});
/* routing rules options */
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-out was moved into action in sb 1.11 */
if (cfg.outbound === 'block-out') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'outbound');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
});
/* server options */
/* auto_firewall was moved into server options */
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
if (!isEmpty(auto_firewall))
uci.delete(uciconfig, uciserver, 'auto_firewall');
uci.foreach(uciconfig, uciserver, (cfg) => {
/* auto_firewall was moved into server options */
if (auto_firewall === '1')
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
/* sniff_override was deprecated in sb 1.11 */
if (!isEmpty(cfg.sniff_override))
uci.delete(uciconfig, cfg['.name'], 'sniff_override');
/* domain_strategy is now pointless without sniff override */
if (!isEmpty(cfg.domain_strategy))
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
});
@@ -10,28 +10,13 @@ import { urldecode, urlencode } from 'luci.http';
import { decodeBase64Str, parseURL, percentDecode, isEmpty, validation } from 'homeproxy';
/*
* Share-link parsers. Every protocol has its own parse_<scheme>_uri() so the
* branches can be read, changed and unit-tested in isolation; parse_uri() only
* dispatches on the scheme and applies the common address/port validation and
* label fallback afterwards.
*
* `features` is the sing-box feature map (with_quic/with_utls/...), `log` is a
* logging callback, both injected so the parsers stay free of ubus/uci.
*/
/*
* Transports sing-box accepts on a V2Ray-style outbound. Anything else
* (xhttp, splithttp, ...) makes sing-box reject the whole generated config,
* so such a node is dropped while importing instead of poisoning every node.
*/
const SUPPORTED_TRANSPORTS = ['ws', 'http', 'grpc', 'httpupgrade', 'quic'];
function unsupported_transport(config) {
return !isEmpty(config.transport) && !(config.transport in SUPPORTED_TRANSPORTS);
}
/* https://shadowsocks.org/guide/sip008.html */
export function parse_sip008_uri(uri) {
return {
label: uri.remarks,
@@ -45,7 +30,6 @@ export function parse_sip008_uri(uri) {
};
};
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
export function parse_anytls_uri(uri) {
uri = split(trim(uri), '://');
@@ -80,7 +64,6 @@ export function parse_http_uri(uri) {
};
};
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
export function parse_hysteria_uri(uri, features, log) {
uri = split(trim(uri), '://');
@@ -113,7 +96,6 @@ export function parse_hysteria_uri(uri, features, log) {
};
};
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
export function parse_hysteria2_uri(uri, features, log) {
uri = split(trim(uri), '://');
@@ -142,7 +124,6 @@ export function parse_hysteria2_uri(uri, features, log) {
};
};
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
export function parse_snell_uri(uri) {
uri = split(trim(uri), '://');
@@ -183,7 +164,6 @@ export function parse_socks_uri(uri) {
export function parse_ss_uri(uri) {
uri = split(trim(uri), '://');
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
@@ -194,18 +174,13 @@ export function parse_ss_uri(uri) {
uri[1] = ss_decoded + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
const url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = [];
if (url.username && url.password)
/* User info encoded with URIComponent; parseURL() already decoded it */
ss_userinfo = [url.username, url.password];
else if (url.username)
/* User info encoded with base64 (padding may have arrived as %3D) */
ss_userinfo = split(decodeBase64Str(url.username) ?? '', ':', 2);
let ss_plugin, ss_plugin_opts;
@@ -213,7 +188,6 @@ export function parse_ss_uri(uri) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
@@ -230,7 +204,6 @@ export function parse_ss_uri(uri) {
};
};
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
export function parse_trojan_uri(uri) {
uri = split(trim(uri), '://');
@@ -270,7 +243,6 @@ export function parse_trojan_uri(uri) {
return config;
};
/* https://github.com/daeuniverse/dae/discussions/182 */
export function parse_tuic_uri(uri, features, log) {
uri = split(trim(uri), '://');
@@ -299,14 +271,12 @@ export function parse_tuic_uri(uri, features, log) {
};
};
/* https://github.com/XTLS/Xray-core/discussions/716 */
export function parse_vless_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
@@ -332,10 +302,6 @@ export function parse_vless_uri(uri, features, log) {
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: features.with_utls ? params.fp : null,
/* sing-box rejects every other value ("unsupported flow") and a single
rejected outbound makes the whole generated config fail, so only the
supported value is imported (the common
"xtls-rprx-vision-udp443" becomes "no flow" instead of breaking). */
vless_flow: (params.security in ['tls', 'reality'] && params.flow === 'xtls-rprx-vision') ? params.flow : null
};
switch (params.type) {
@@ -372,11 +338,9 @@ export function parse_vless_uri(uri, features, log) {
return config;
};
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
export function parse_vmess_uri(uri, features, log) {
uri = split(trim(uri), '://');
/* "Lovely" shadowrocket format */
if (match(uri, /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
@@ -393,7 +357,6 @@ export function parse_vmess_uri(uri, features, log) {
if (payload.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (payload.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
return null;
@@ -404,13 +367,6 @@ export function parse_vmess_uri(uri, features, log) {
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (payload.aid && int(payload.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
* return null;
* }
*/
const config = {
label: payload.ps ? urldecode(payload.ps) : null,
@@ -5,21 +5,12 @@
SCRIPTS_DIR="/etc/homeproxy/scripts"
# update_resources.sh exit codes: 0 updated, 1 failed, 2 another run holds the
# lock, 3 already up to date.
changed=0
for i in "china_ip4" "china_ip6" "gfw_list" "china_list" "geoip_cn" "geosite_cn"; do
"$SCRIPTS_DIR"/update_resources.sh "$i"
[ "$?" -eq 0 ] && changed=1
done
# The nft sets (china_ip4/ip6.txt) and the dnsmasq snippets (gfw_list.txt,
# china_list.txt) are rendered from those files, and the client rule-sets are
# loaded from the two .srs files, so a downloaded list only takes effect after
# they have been regenerated. Without this the daily cron update rewrote the
# files on disk and nothing else.
[ "$changed" -eq 0 ] || /etc/init.d/homeproxy refresh_lists
# update_subscriptions.uc restarts the service itself when it changed the UCI
# configuration.
"$SCRIPTS_DIR"/update_subscriptions.uc
@@ -35,12 +35,6 @@ check_list_update() {
return 2
fi
# --header= is the only header switch understood by all three wget
# implementations this runs on (uclient-fetch, GNU wget, busybox wget);
# the previous --header-file= is rejected by uclient-fetch and GNU wget
# alike, which made every version probe fail whenever a GitHub token was
# configured. The token is therefore visible in `ps` for the duration of
# the request; that is the trade-off for a single code path.
local api_url="https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1"
local list_info
if [ -n "$github_token" ]; then
@@ -20,7 +20,6 @@ import {
import { parse_uri } from 'parse_uri';
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
@@ -44,9 +43,7 @@ if (routing_mode !== 'custom') {
main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil';
main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil';
}
/* UCI config end */
/* String helper start */
function filter_check(name) {
if (isEmpty(name) || filter_mode === 'disabled' || isEmpty(filter_keywords))
return false;
@@ -68,9 +65,7 @@ function filter_check(name) {
return ret;
}
/* String helper end */
/* Common var start */
const node_cache = {},
node_result = [];
@@ -78,9 +73,7 @@ const ubus = connect();
const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {};
if (isEmpty(sing_features))
log('Warning: Failed to query sing-box features via ubus, assuming defaults.');
/* Common var end */
/* Log */
system(`mkdir -p ${RUN_DIR}`);
function log(...args) {
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
@@ -88,20 +81,10 @@ function log(...args) {
logfile.close();
}
/*
* Subscription URLs usually carry the provider token in the query string;
* the log is surfaced in LuCI, so keep it out.
*/
function redact(url) {
return replace(url, /[?#].*$/, '');
}
/*
* mkdir is the lock: atomic everywhere this runs and no flock binding needed.
* A lock older than LOCK_STALE is broken, so a killed run cannot block every
* later update. Two concurrent runs (cron + the LuCI button) used to
* interleave commits and service restarts.
*/
const LOCK_DIR = RUN_DIR + '/update_subscriptions.lock';
const LOCK_STALE = 600;
@@ -126,11 +109,6 @@ function release_lock() {
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
}
/*
* sing-box rejects the whole generated config over one malformed node, and the
* generator keeps the old config when it fails, so a node that cannot possibly
* work must not reach UCI. Returns the reason, or null when the node is usable.
*/
function node_problem(cfg) {
if (isEmpty(cfg.address) || isEmpty(cfg.port))
return 'missing address or port';
@@ -199,12 +177,9 @@ function main() {
try {
nodes = json(res).servers || json(res);
/* Shadowsocks SIP008 format */
if (nodes[0].server && nodes[0].method)
map(nodes, (_, i) => nodes[i].nodetype = 'sip008');
} catch(e) {
/* Clash/mihomo subscriptions are YAML, not base64; say so instead
of reporting a base64/JSON failure followed by "no valid node". */
if (match(res, /(^|\n)[ \t]*(proxies|proxy-groups)[ \t]*:/)) {
log(sprintf('Unsupported subscription format (Clash/mihomo YAML) at %s.', shown_url));
continue;
@@ -222,9 +197,6 @@ function main() {
const config = parse_uri(node, sing_features, log);
if (isEmpty(config)) {
/* No credentials in the log: only the scheme says whether a
parser is missing or the entry is simply not a node. Only
the first few are printed, the rest is summarized below. */
if (skipped < 5)
log(sprintf('Skipping unparsable entry (%s) from %s.',
type(node) === 'string' ? replace(node, /:.*$/, '') : type(node), shown_url));
@@ -288,11 +260,9 @@ function main() {
let added = 0, removed = 0, changed = false;
uci.foreach(uciconfig, ucinode, (cfg) => {
/* Nodes created by the user */
if (!cfg.grouphash)
return null;
/* Empty object - failed to fetch nodes, or subscription URL not yet processed */
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
return null;
@@ -304,14 +274,10 @@ function main() {
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
} else {
map(keys(cfg), (v) => {
/* skip the cursor's own metadata (.name/.type/...): deleting
those is a no-op and would mark every run as changed */
if (substr(v, 0, 1) === '.')
return null;
if (v in node_cache[cfg.grouphash][cfg['.name']]) {
/* only write what actually differs, so an unchanged
subscription does not fake a change below */
const fresh = node_cache[cfg.grouphash][cfg['.name']][v];
const current = uci.get(uciconfig, cfg['.name'], v);
if (type(fresh) === 'array' ? (sprintf('%J', uci.get_all(uciconfig, cfg['.name'], v) || []) !== sprintf('%J', fresh))
@@ -342,9 +308,6 @@ function main() {
});
uci.commit(uciconfig);
/* via_proxy keeps the service up during the fetch, but a configuration
change still has to be applied - it used to be committed and then
ignored until the next restart. */
let need_restart = (via_proxy !== '1') || changed;
if (!isEmpty(main_node)) {
const first_server = uci.get_first(uciconfig, ucinode);
@@ -410,7 +373,6 @@ function main() {
}
}
/* Scrub stale urltest member references in custom routing nodes */
uci.foreach(uciconfig, 'routing_node', (cfg) => {
if (cfg.node !== 'urltest' || isEmpty(cfg.urltest_nodes))
return null;
@@ -3,14 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Dump the LuCI form definition of the homeproxy node/client/server views as
* JSON, using a minimal mock of the LuCI client runtime. It is meant to be run
* against two checkouts (e.g. HEAD and the working tree) and the resulting
* snapshots diffed, so that a pure refactor can be proven not to change any
* option name, dependency, default or label.
*
* Usage: node tests/luci-form-snapshot.js <repo-root> [node|client|server]
*/
'use strict';
@@ -18,7 +10,6 @@
const fs = require('fs');
const path = require('path');
/* --- LuCI runtime mock start ------------------------------------------- */
String.prototype.format = function (...args) {
const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
@@ -171,8 +162,6 @@ const rpc = { declare: () => () => Promise.resolve({}) };
const fsMock = { exec_direct: () => Promise.resolve(''), read: () => Promise.resolve(''), write: () => Promise.resolve() };
const widgets = { DeviceSelect: class {} };
/* tools.firewall is used by the client view for the LAN ACL lists; the mock
has to return an option object like the real addIPOption/addMACOption. */
const fwtool = {
addIPOption: (section, _policy, name, title, _desc, _family, _hosts, _optional) => section.option(form.DynamicList, name, title),
addMACOption: (section, _policy, name, title, _desc, _hosts) => section.option(form.DynamicList, name, title)
@@ -186,7 +175,6 @@ const deps = {
'tools.firewall': fwtool
};
/* --- LuCI runtime mock end --------------------------------------------- */
function loadLuciModule(file, extraDeps) {
let src = fs.readFileSync(file, 'utf8');
@@ -194,7 +182,6 @@ function loadLuciModule(file, extraDeps) {
(_m, mod, alias) => `const ${alias} = __deps[${JSON.stringify(mod)}];`);
src = src.replace(/^'require ([^']+)';$/gm,
(_m, mod) => `const ${mod.split('.').pop().replace(/[^\w]/g, '_')} = __deps[${JSON.stringify(mod)}];`);
/* eslint-disable-next-line no-new-func */
const factory = new Function('__deps', '_', 'E', 'L', src);
return factory(Object.assign({}, deps, extraDeps), _, E, L);
}
-11
View File
@@ -1,16 +1,5 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# One-shot entry point for the test suite:
#
# tests/run.sh
#
# Translation coverage and the LuCI form snapshots run locally (they only need
# python3 / node). The ucode tests run locally when ucode and sing-box are
# available, otherwise the checkout is copied to $HP_TEST_HOST and run there.
#
# HP_TEST_HOST=root@192.168.1.1 tests/run.sh
# HP_TEST_DIR=/tmp/hp-tests tests/run.sh
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${HP_TEST_HOST:-root@192.168.1.1}"
@@ -2,14 +2,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Test double for root/etc/homeproxy/scripts/homeproxy.uc.
*
* Only `validation()` is stubbed: the real one shells out to
* /sbin/validate_data, which does not exist outside OpenWrt. Everything else
* (isEmpty, decodeBase64Str, parseURL) is a verbatim copy so the parser tests
* exercise the same string handling as production. Keep these in sync when
* homeproxy.uc changes.
*/
import { urldecode_params } from 'luci.http';
@@ -33,7 +25,6 @@ export function decodeBase64Str(str) {
return b64dec(str);
};
/* Pure-ucode stand-in for /sbin/validate_data (host/port/ip4/ip6/hostname). */
export function validation(datatype, data) {
if (!datatype || !data)
return null;
@@ -62,14 +53,6 @@ export function validation(datatype, data) {
}
};
/*
* Decode %XX escapes without touching '+'. urldecode() maps '+' to a space,
* which corrupts base64 userinfo ("+" is part of the alphabet), so share-link
* userinfo is percent-decoded with this instead.
*/
/*
* ucode has no tolower(), so both hex cases are mapped by hand.
*/
function hexValue(ch) {
const lower = index('0123456789abcdef', ch);
if (lower >= 0)
@@ -80,10 +63,6 @@ function hexValue(ch) {
return upper < 0 ? -1 : 10 + upper;
}
/*
* ucode's regex engine rejects \x00 inside a character class ("Missing ']'"),
* so control bytes are checked by hand.
*/
function hasControlChar(str) {
for (let i = 0; i < length(str); i++) {
const code = ord(str, i);
@@ -160,7 +139,6 @@ export function parseURL(url) {
return null;
if (objurl.userinfo) {
/* keep in sync with the production parser (homeproxy.uc) */
let userinfo = percentDecode(objurl.userinfo);
userinfo = replace(userinfo, /:(.+)$/, (_, val) => {
-14
View File
@@ -1,11 +1,5 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Run the ucode-level tests: the parse_uri unit tests, the fw4 inventory check
# and the generator regression fixtures. Requires ucode; sing-box is needed for
# the generator cases (they validate the emitted config with `sing-box check`).
#
# Usage: sh tests/ucode/run.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-ucode-tests}"
@@ -16,8 +10,6 @@ FAILED=0
echo "== ucode syntax check =="
for file in "$ROOT"/root/etc/homeproxy/scripts/*.uc "$ROOT"/root/usr/share/rpcd/ucode/*; do
[ -f "$file" ] || continue
# Modules (with export statements) cannot be compiled as a program; they
# are loaded through `import` below instead.
case "$file" in
*homeproxy.uc|*parse_uri.uc) continue ;;
esac
@@ -82,10 +74,6 @@ else
fi
echo "== wget flavour selection =="
# A stub /usr/bin/wget whose --version answer comes from the environment, so
# both flavours can be exercised on a single host. The empty-answer/non-zero
# case is uclient-fetch, whose getopt rejects --version just like it rejects
# -nv before sending anything.
rm -rf "$WORK/wget_flavor"
mkdir -p "$WORK/wget_flavor"
cat > "$WORK/wget_flavor/wget-stub" <<'STUB'
@@ -125,8 +113,6 @@ for flavor in gnu compat; do
done
echo "== real uclient-fetch compatibility =="
# uclient-fetch is the default /usr/bin/wget on OpenWrt/ImmortalWrt, so a
# GNU-only option anywhere in the generated command line has to fail here.
if [ -x /bin/uclient-fetch ]; then
rm -rf "$WORK/uclient"
mkdir -p "$WORK/uclient"
@@ -1,15 +1,5 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Render firewall_post.ut and verify the homeproxy fw4 objects survive as real
# nft statements.
#
# The template opens with `{%-`, which trims the whitespace in front of it: any
# text placed above that tag loses its trailing newline and is glued onto the
# first generated statement, turning it into a comment. That silently dropped
# every homeproxy chain/set from the fw4 ruleset once, so keep this check.
#
# Usage: sh tests/ucode/test_firewall_template.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
@@ -23,8 +13,6 @@ if ! utpl -S "$ROOT/root/etc/homeproxy/scripts/firewall_post.ut" > "$OUT" 2>"/tm
exit 1
fi
# Declared unconditionally by the template, so they must always be present as
# standalone statements.
for name in homeproxy_local_addr_v4 homeproxy_wan_proxy_addr_v4; do
if ! grep -q "^set $name {" "$OUT"; then
echo "FAIL: 'set $name {' is missing from the rendered firewall template"
@@ -33,8 +21,6 @@ for name in homeproxy_local_addr_v4 homeproxy_wan_proxy_addr_v4; do
fi
done
# A statement glued onto the preceding comment looks like
# "# <comment text>.set homeproxy_x {" -- exactly the failure mode above.
if grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" > "/dev/null"; then
echo "FAIL: an nft statement is glued onto a comment line"
grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" | head -2
@@ -1,13 +1,5 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The fw4 chain/set inventory in scripts/fw4_names.sh must stay in sync with
# the objects declared by scripts/firewall_post.ut: the first drives the
# cleanup in init.d/homeproxy, the second creates them. A drift means either a
# leftover object or a delete of something that never existed (which used to
# abort the whole cleanup batch).
#
# Usage: sh tests/ucode/test_fw4_names.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
@@ -18,7 +10,6 @@ PRE="$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc"
FAILED=0
# shellcheck source=/dev/null
. "$NAMES"
check_group() {
@@ -57,8 +48,6 @@ post_sets="$(grep -oE '^set homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | s
check_group "chain" "$HP_FW4_CHAINS" "$post_chains"
check_group "set" "$HP_FW4_SETS" "$post_sets"
# firewall_pre.uc only writes forward/input accept rules; it must not own any
# chain/set of its own.
pre_refs="$(grep -oE 'homeproxy_[a-z0-9_]+' "$PRE" | sort -u)"
if [ -n "$pre_refs" ]; then
echo "NOTE: firewall_pre.uc references fw4 objects:$pre_refs"
@@ -1,13 +1,5 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Run generate_client.uc / generate_server.uc against the UCI fixtures and let
# sing-box validate the result. The generators read /etc/config and write
# /var/run, so this script materialises an isolated copy of them: homeproxy.uc
# is rewritten to point HP_DIR/RUN_DIR at a scratch directory, and the uci
# cursor is pointed at the fixture config.
#
# Usage: sh tests/ucode/test_generators.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-generator-test}"
@@ -29,8 +21,6 @@ run_case() {
: > "$dir/resources/proxy_list.txt"
if [ "$local_rs" = "1" ]; then
# with the .srs files that update_resources.sh downloads in place, the
# generator has to load them instead of the remote URLs
printf '%s' '{"version":1,"rules":[{"domain_suffix":["example.com"]}]}' > "$dir/rs-src.json"
if ! sing-box rule-set compile "$dir/rs-src.json" -o "$dir/resources/geoip_cn.srs" \
|| ! sing-box rule-set compile "$dir/rs-src.json" -o "$dir/resources/geosite_cn.srs"; then
@@ -41,7 +31,6 @@ run_case() {
fi
if grep -q "__RULESET_DIR__" "$fixture"; then
# The fixture needs a real local rule-set on disk.
printf '%s' '{"version":1,"rules":[{"domain_suffix":["example.com"]}]}' > "$dir/ruleset/src.json"
if ! sing-box rule-set compile "$dir/ruleset/src.json" -o "$dir/ruleset/test.srs"; then
echo "FAIL: $name: could not compile the local rule-set fixture"
@@ -83,10 +72,6 @@ run_case() {
run_case client "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json
# Golden comparison for the client generator. `sing-box check` only proves the
# config parses; this proves the generated config did not change silently (rule
# order, DNS routing decisions, whether a field is emitted at all). The work
# directory is normalized out of the paths before comparing.
CLIENT_GOLDEN="$ROOT/tests/snapshots/client.generated.json"
CLIENT_OUT="$WORK/client/run/sing-box-c.json"
if [ ! -f "$CLIENT_GOLDEN" ]; then
@@ -103,10 +88,6 @@ else
FAILED=1
fi
# The preset remote rule-sets must be fetched through the node. A direct
# download depends on the CDN staying reachable from mainland China and fails
# intermittently under DNS pollution, which shows up as "open connection to
# <ip>:443 using outbound/direct[direct]: i/o timeout" in sing-box-c.log.
if grep -q '"detour": "direct-out"' "$WORK/client/run/sing-box-c.json"; then
echo "FAIL: client: a remote rule-set would still be downloaded directly"
FAILED=1
@@ -116,9 +97,6 @@ if ! grep -q '"detour": "main-out"' "$WORK/client/run/sing-box-c.json"; then
FAILED=1
fi
# Local rule-sets (downloaded by update_resources.sh) must be preferred over
# the remote URLs, so a cold start does not depend on reaching the CDN through
# the node.
run_case client-local "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json 1
if grep -q '"type": "local"' "$WORK/client-local/run/sing-box-c.json" \
&& ! grep -q '"type": "remote"' "$WORK/client-local/run/sing-box-c.json"; then
@@ -129,12 +107,6 @@ else
fi
run_case custom "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json
# Custom mode without a default outbound: the default shape of the custom UI.
# `sing-box check` alone is not enough -- before the fix the generator emitted a
# config with none of the user's DNS servers, nodes, rules or rule-sets, and
# sing-box 1.14 then refused to start it for a missing
# route.default_domain_resolver. Assert on the emitted config so a partial
# regression cannot pass as green.
run_case custom_no_default "$ROOT/tests/fixtures/generators/custom-no-default-outbound.uci" generate_client.uc sing-box-c.json
CUSTOM_OUT="$WORK/custom_no_default/run/sing-box-c.json"
@@ -150,12 +122,8 @@ assert_json() {
fi
}
# sing-box always emits default-dns and system-dns, so more than one DNS server
# is always present and 1.14 requires a global domain resolver.
assert_json "custom/no-default: a global domain resolver is declared" '"default_domain_resolver"'
assert_json "custom/no-default: the user DNS server survived" '"cfg-DNS_Server_Proxy-dns"'
# A sing-box rule has no label field, so the UCI `option label` never reaches the
# JSON. Match the marker the rule body actually carries.
assert_json "custom/no-default: the user DNS rule survived" 'dns-rule-marker.example.com'
assert_json "custom/no-default: the urltest group survived" '"cfg-Routing_Node_Proxy-out"'
assert_json "custom/no-default: its member nodes survived" '"cfg-n_hk-out"'
@@ -3,10 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Regression tests for homeproxy.uc helpers, focused on executeCommand():
* return shape, stderr/exit-code capture, binary detection and (most
* importantly) that the temporary descriptors are closed on every run.
*/
'use strict';
@@ -32,7 +28,6 @@ function fd_count() {
return n;
}
/* successful command */
const ok = executeCommand('echo', 'hello');
expect('ok.exitcode', ok.exitcode, 0);
expect('ok.stdout', ok.stdout, 'hello\n');
@@ -40,29 +35,22 @@ expect('ok.stderr', ok.stderr, '');
expect('ok.binary', ok.binary, false);
expect('ok.command', ok.command, 'echo hello');
/* failing command keeps stderr and the exit code */
const bad = executeCommand('sh', '-c', shellQuote('echo oops >&2; exit 3'));
expect('bad.exitcode', bad.exitcode, 3);
expect('bad.stderr', bad.stderr, 'oops\n');
expect('bad.stdout', bad.stdout, '');
/* nondescript exit code */
expect('false.exitcode', executeCommand('false').exitcode, 1);
/* binary output is detected and withheld */
const bin = executeCommand('sh', '-c', shellQuote('printf "\\001\\002\\003"'));
expect('bin.binary', bin.binary, true);
expect('bin.stdout', bin.stdout, null);
/* output larger than the old one-shot 512 KiB read must not be truncated
(subscription lists above that size used to arrive cut in half) */
const big_want = 700 * 1024;
const big = executeCommand('sh', '-c', shellQuote('yes a | head -c ' + big_want));
expect('big.exitcode', big.exitcode, 0);
expect('big.length', length(big.stdout), big_want);
/* isValidCIDR(): the prefix needs the same strict validation as the address,
otherwise a poisoned resource file reaches the nftables template */
expect('cidr.ok', isValidCIDR('1.2.3.4/24', 4), true);
expect('cidr.bare', isValidCIDR('1.2.3.4', 4), true);
expect('cidr.bad-prefix', isValidCIDR('1.2.3.4/abc', 4), false);
@@ -73,7 +61,6 @@ expect('cidr.injection', isValidCIDR('1.2.3.4/0 } flush ruleset; table inet x {'
expect('cidr.v6-ok', isValidCIDR('2001:db8::/32', 6), true);
expect('cidr.v6-injection', isValidCIDR('2001:db8::/32 } flush ruleset;', 6), false);
/* isValidPEM(): certificate vs private key, boundaries and body */
const pem_cert = '-----BEGIN CERTIFICATE-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END CERTIFICATE-----';
const pem_key = '-----BEGIN RSA PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END RSA PRIVATE KEY-----';
expect('pem.cert', isValidPEM(pem_cert, false), true);
@@ -83,7 +70,6 @@ expect('pem.key-as-cert', isValidPEM(pem_key, false), false);
expect('pem.garbage', isValidPEM('not a pem at all', false), false);
expect('pem.empty', isValidPEM('', false), false);
/* descriptors must not leak across calls */
const before = fd_count();
for (let i = 0; i < 200; i++)
executeCommand('true');
@@ -3,11 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Exceptional-path test for executeCommand(): the copy of homeproxy.uc staged
* next to this file has its `system()` call replaced by `die()`, so every call
* throws before the descriptors are read. ucode has no `finally`, so this
* checks that the catch block still closes both descriptors.
*/
'use strict';
@@ -3,10 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Unit tests for the share-link parsers in parse_uri.uc. Run through
* tests/ucode/run.sh, which stages parse_uri.uc next to the mocked
* homeproxy.uc.
*/
'use strict';
@@ -44,13 +40,11 @@ function expect_null(name, cfg) {
}
}
/* anytls */
expect_fields('anytls', parse_uri('anytls://secret@a.example.com:443?sni=a.example.com&insecure=1#AnyTLS-Node', FEATURES, LOG), {
label: 'AnyTLS-Node', type: 'anytls', address: 'a.example.com', port: '443',
password: 'secret', tls: '1', tls_sni: 'a.example.com', tls_insecure: '1'
});
/* http / https */
expect_fields('https', parse_uri('https://user:pass@b.example.com:8443#HTTPS-Node', FEATURES, LOG), {
label: 'HTTPS-Node', type: 'http', address: 'b.example.com', port: '8443',
username: 'user', password: 'pass', tls: '1'
@@ -59,7 +53,6 @@ expect_fields('http', parse_uri('http://c.example.com:8080#HTTP-Node', FEATURES,
label: 'HTTP-Node', type: 'http', address: 'c.example.com', port: '8080', tls: '0'
});
/* hysteria */
expect_fields('hysteria', parse_uri('hysteria://d.example.com:36712?auth=pass&peer=d.example.com&insecure=1&upmbps=100&downmbps=200&alpn=h3&protocol=udp#Hy1', FEATURES, LOG), {
label: 'Hy1', type: 'hysteria', address: 'd.example.com', port: '36712',
hysteria_protocol: 'udp', hysteria_auth_type: 'string', hysteria_auth_payload: 'pass',
@@ -67,7 +60,6 @@ expect_fields('hysteria', parse_uri('hysteria://d.example.com:36712?auth=pass&pe
tls: '1', tls_insecure: '1', tls_sni: 'd.example.com', tls_alpn: 'h3'
});
/* hysteria2 */
expect_fields('hysteria2', parse_uri('hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2', FEATURES, LOG), {
label: 'Hy2', type: 'hysteria2', address: 'e.example.com', port: '443',
password: 'pass', hysteria_obfs_type: 'salamander', hysteria_obfs_password: 'op',
@@ -77,14 +69,12 @@ expect_fields('hy2-alias', parse_uri('hy2://pass@e.example.com:443#Hy2Alias', FE
label: 'Hy2Alias', type: 'hysteria2', address: 'e.example.com', port: '443'
});
/* snell */
expect_fields('snell', parse_uri('snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com&reuse=1#Snell', FEATURES, LOG), {
label: 'Snell', type: 'snell', address: 'f.example.com', port: '443',
password: 'pskvalue', snell_version: '4', snell_obfs_mode: 'http',
snell_obfs_host: 'bing.com', snell_reuse: '1'
});
/* socks */
expect_fields('socks5', parse_uri('socks5://user:pass@g.example.com:1080#Socks', FEATURES, LOG), {
label: 'Socks', type: 'socks', address: 'g.example.com', port: '1080',
username: 'user', password: 'pass', socks_version: '5'
@@ -93,25 +83,21 @@ expect_fields('socks4', parse_uri('socks4://h.example.com:1080#Socks4', FEATURES
socks_version: '4'
});
/* shadowsocks: SIP002 with base64 user info */
expect_fields('ss-sip002-b64', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS-Node', FEATURES, LOG), {
label: 'SS-Node', type: 'shadowsocks', address: 'i.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* shadowsocks: SIP002 with plugin */
expect_fields('ss-sip002-plugin', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@j.example.com:8388?plugin=simple-obfs%3Bobfs%3Dhttp#SS-Plugin', FEATURES, LOG), {
label: 'SS-Plugin', type: 'shadowsocks', address: 'j.example.com', port: '8388',
shadowsocks_plugin: 'obfs-local', shadowsocks_plugin_opts: 'obfs=http'
});
/* shadowsocks: Shadowrocket whole-payload base64 */
expect_fields('ss-shadowrocket', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmRAay5leGFtcGxlLmNvbTo4Mzg4#SS-SR', FEATURES, LOG), {
label: 'SS-SR', type: 'shadowsocks', address: 'k.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* trojan */
expect_fields('trojan-ws', parse_uri('trojan://pass@l.example.com:443?type=ws&host=l.example.com&path=%2Fpath%3Fed%3D2048&sni=l.example.com#TrojanWs', FEATURES, LOG), {
label: 'TrojanWs', type: 'trojan', address: 'l.example.com', port: '443', password: 'pass',
transport: 'ws', ws_host: 'l.example.com', ws_path: '/path',
@@ -122,14 +108,12 @@ expect_fields('trojan-grpc', parse_uri('trojan://pass@l.example.com:443?type=grp
transport: 'grpc', grpc_servicename: 'gs'
});
/* tuic */
expect_fields('tuic', parse_uri('tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&udp_relay_mode=native&alpn=h3&sni=m.example.com#Tuic', FEATURES, LOG), {
label: 'Tuic', type: 'tuic', address: 'm.example.com', port: '443',
uuid: 'tuic-uuid', password: 'pass', tuic_congestion_control: 'bbr',
tuic_udp_relay_mode: 'native', tls: '1', tls_sni: 'm.example.com', tls_alpn: [ 'h3' ]
});
/* vless */
expect_fields('vless-reality-grpc', parse_uri('vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&fp=chrome&type=grpc&serviceName=gs&sni=n.example.com&flow=xtls-rprx-vision#Vless', FEATURES, LOG), {
label: 'Vless', type: 'vless', address: 'n.example.com', port: '443', uuid: 'vless-uuid',
transport: 'grpc', grpc_servicename: 'gs', tls: '1', tls_sni: 'n.example.com',
@@ -142,7 +126,6 @@ expect_fields('vless-ws-early-data', parse_uri('vless://vless-uuid@o.example.com
tls: '1', tls_alpn: [ 'h2', 'http/1.1' ]
});
/* vmess */
expect_fields('vmess-ws', parse_uri('vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0=', FEATURES, LOG), {
label: 'VMess', type: 'vmess', address: 'p.example.com', port: '443',
uuid: '3af88561-9c69-4b19-8f7e-f08d580bc339', vmess_alterid: '0', vmess_encrypt: 'auto',
@@ -150,7 +133,6 @@ expect_fields('vmess-ws', parse_uri('vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQ
tls: '1', tls_sni: 'p.example.com'
});
/* sip008 object */
expect_fields('sip008', parse_uri({
nodetype: 'sip008', remarks: 'SIP008', server: 'q.example.com', server_port: 8388,
method: 'aes-256-gcm', password: 'pw'
@@ -159,30 +141,23 @@ expect_fields('sip008', parse_uri({
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'pw'
});
/* rejection paths */
expect_null('vless-kcp-unsupported', parse_uri('vless://u@r.example.com:443?type=kcp#Kcp', FEATURES, LOG));
expect_null('hysteria-without-quic', parse_uri('hysteria://s.example.com:443?auth=a#NoQuic', NO_QUIC, LOG));
expect_null('tuic-without-quic', parse_uri('tuic://u:p@s.example.com:443#NoQuic', NO_QUIC, LOG));
expect_null('invalid-port', parse_uri('vless://u@t.example.com:99999?security=tls#BadPort', FEATURES, LOG));
expect_null('unknown-scheme', parse_uri('unknown://u@t.example.com:443', FEATURES, LOG));
/* sing-box has no xhttp transport; importing it used to make the whole
generated config fail with "unknown transport type" */
expect_null('vless-xhttp-unsupported', parse_uri('vless://u@t.example.com:443?security=tls&type=xhttp#Xhttp', FEATURES, LOG));
/* percent-encoded base64 userinfo (%3D instead of =): used to lose method and
password, which made the node break the generated config */
expect_fields('ss-percent-encoded-userinfo', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ%3D@j.example.com:8388#SSEnc', FEATURES, LOG), {
label: 'SSEnc', type: 'shadowsocks', address: 'j.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* unsupported flow must import as "no flow" instead of breaking sing-box */
expect_fields('vless-flow-udp443-dropped', parse_uri('vless://vless-uuid@n.example.com:443?security=tls&flow=xtls-rprx-vision-udp443&sni=n.example.com#FlowDrop', FEATURES, LOG), {
label: 'FlowDrop', type: 'vless', address: 'n.example.com', port: '443',
uuid: 'vless-uuid', tls: '1', tls_sni: 'n.example.com', vless_flow: null
});
/* punctuation in a password survives now that userinfo is not whitelisted */
expect_fields('trojan-punctuation-password', parse_uri('trojan://pa!ss~1@m.example.com:443?sni=m.example.com#Punct', FEATURES, LOG), {
label: 'Punct', type: 'trojan', address: 'm.example.com', port: '443',
password: 'pa!ss~1', tls: '1', tls_sni: 'm.example.com'
@@ -3,17 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2026 ImmortalWrt.org
*
* Regression tests for the wget flavour probe and for the command line
* wGETVerbose() builds from it.
*
* OpenWrt/ImmortalWrt's default /usr/bin/wget is uclient-fetch, which rejects
* GNU-only flags such as -nv with "unrecognized option: n" *before* it opens a
* connection, so a single GNU-ism turns every subscription update into
* "no valid node found". run.sh stages a copy of homeproxy.uc whose
* /usr/bin/wget points at a stub; HP_WGET_STUB_VERSION steers the answer of
* the `--version` probe (empty output plus a non-zero exit mimics
* uclient-fetch, whose getopt rejects the option and returns 1).
*/
'use strict';
@@ -39,7 +28,6 @@ function has(list, value) {
return false;
}
/* The value following the first occurrence of `key` */
function after(list, key) {
let prev = null;
@@ -53,7 +41,6 @@ function after(list, key) {
return null;
}
/* Classification of the `wget --version` probe. */
expect('gnu.version', classifyWgetFlavor(0, 'GNU Wget 1.25.0 built on linux-gnu.\n'), 'gnu');
expect('gnu.multiline', classifyWgetFlavor(0, "GNU Wget 1.21.4 built on linux-gnu.\n\nCopyright (C) 2015 Free Software Foundation.\n"), 'gnu');
expect('uclient.rejects-version', classifyWgetFlavor(1, null), 'compat');
@@ -61,7 +48,6 @@ expect('busybox.version', classifyWgetFlavor(0, 'BusyBox v1.36.1 (2024-01-01) mu
expect('empty.stdout', classifyWgetFlavor(0, ''), 'compat');
expect('missing.binary', classifyWgetFlavor(127, null), 'compat');
/* wgetFlavor() probes the stub and caches the answer. */
const want_flavor = getenv('HP_WGET_EXPECT_FLAVOR');
expect('flavor', wgetFlavor(), want_flavor);
expect('flavor.cached', wgetFlavor(), want_flavor);
@@ -79,8 +65,6 @@ expect('timeout flag', after(args, '-T'), '10');
expect('output flag', has(args, '-O-'), true);
expect('url', has(args, 'https://example.invalid/subs?token=abc'), true);
/* -nv is GNU wget's --no-verbose; every other wget on the target rejects it,
* and -q is the meter switch they all share. */
expect('meter flag', has(args, want_flavor === 'gnu' ? '-nv' : '-q'), true);
expect('no gnu-only flag', has(args, '-nv'), want_flavor === 'gnu');
expect('no long options', has(args, '--user-agent') || has(args, '--timeout=10'), false);
@@ -3,17 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2026 ImmortalWrt.org
*
* Feeds the command line wGETVerbose() builds to the target's real
* uclient-fetch - the default /usr/bin/wget on OpenWrt/ImmortalWrt. run.sh
* rewrites the module's wget path to /bin/uclient-fetch and skips this file
* where that binary does not exist.
*
* A closed loopback port is the entire request: what matters is that
* uclient-fetch gets past getopt. Before the flavour probe existed it did not -
* `-nv` made it print "unrecognized option: n" and exit before the request was
* sent, so every subscription fetch came back empty. Any failure observed here
* must therefore come from the network, never from the option parser.
*/
'use strict';
@@ -30,7 +19,6 @@ function expect(name, actual, want) {
}
}
/* uclient-fetch has no --version and rejects it, so it is classified compat. */
expect('flavor', wgetFlavor(), 'compat');
const res = wGETVerbose('http://127.0.0.1:1/subs', 'HomeProxy test UA');
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-honk
PKG_VERSION:=1.1.4
PKG_RELEASE:=21
PKG_RELEASE:=22
LUCI_TITLE:=LuCI support for honk
LUCI_PKGARCH:=all
@@ -10,6 +10,14 @@ if [ "$dash" = "none" ]; then
: > /etc/honk/config.d/api.dae
fi
# Drop options of the auto-subscribe feature removed from this package
uci -q show honk 2>/dev/null | sed -n 's/^\(.*\)\.subscribe_[a-z_]*=.*$/\1/p' | sort -u | while read -r s; do
for opt in subscribe_auto_update subscribe_update_week_time subscribe_update_day_time; do
uci -q delete "$s.$opt"
done
uci -q commit honk
done
# Migrate legacy /etc/honk/dashboard directory
if [ -d /etc/honk/dashboard ] && [ ! -d /etc/honk/doona ]; then
mv /etc/honk/dashboard /etc/honk/doona
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.311
PKG_RELEASE:=23
PKG_VERSION:=0.1.313
PKG_RELEASE:=24
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.4
PKG_RELEASE:=326
PKG_RELEASE:=327
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -315,9 +315,6 @@ o:depends({ _acl_node_bool = "1", _diff_global_node = "1" })
o = s:taboption("Main", HideValue, "_node_sel_shunt", "")
o:depends({ node = "__always__" })
o = s:taboption("Main", HideValue, "_node_sel_other", "")
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
---- DNS
o = s:taboption("DNS", ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
o.default = "chinadns-ng"
@@ -347,7 +344,7 @@ end
if has_xray then
o:value("xray", "Xray")
end
o:depends({ _acl_node_bool = "1", _node_sel_other = "1", _diff_global_node = "1" })
o:depends({ _acl_node_bool = "1", _node_sel_shunt = "", _diff_global_node = "1" })
o.write = function(self, section, value)
if value == "dns2socks" then
m:del(section, "v2ray_dns_mode")
@@ -136,13 +136,7 @@ if (has_singbox or has_xray) and #nodes_table > 0 then
tips.cfgvalue = function(t, n)
return string.format('<a style="color: red">%s</a>', translate("There are no available nodes, please add or subscribe nodes first."))
end
tips:depends("_node", "1")
for k, v in pairs(shunt_list) do
tips:depends("node", v.id)
end
for k, v in pairs(balancing_list) do
tips:depends("node", v.id)
end
tips:depends({ node = "", ['!reverse'] = true })
end
end
@@ -150,7 +144,7 @@ o = s:taboption("Main", Value, "node_socks_port", translate("Node") .. " Socks "
o.default = 1070
o.placeholder = 1070
o.datatype = "range(1,65535)"
o:depends("_node", "1")
o:depends({ node = "", ['!reverse'] = true })
--[[
if has_singbox or has_xray then
o = s:taboption("Main", Value, "node_http_port", translate("Node") .. " HTTP " .. translate("Listen Port") .. " " .. translate("0 is not use"))
@@ -160,22 +154,16 @@ end
]]--
o = s:taboption("Main", Flag, "node_socks_bind_local", translate("Node") .. " Socks " .. translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
o.default = "1"
o:depends("_node", "1")
o:depends({ node = "", ['!reverse'] = true })
o = s:taboption("Main", HideValue, "node_save_before", "")
o.value = current_node[".name"]
o.cbid = function(self, section) return "node_save_before" end
o = s:taboption("Main", HideValue, "_node", "")
o:depends({ node = "", ['!reverse'] = true })
-- Node → DNS Depends Settings
o = s:taboption("Main", HideValue, "_node_sel_shunt", "")
o:depends({ node = "__always__" })
o = s:taboption("Main", HideValue, "_node_sel_other", "")
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
-- [[ DNS Settings ]]--
s:tab("DNS", translate("DNS"))
@@ -279,8 +267,8 @@ end
if has_xray then
o:value("xray", "Xray")
end
o:depends({ dns_shunt = "chinadns-ng", _node_sel_other = "1" })
o:depends({ dns_shunt = "dnsmasq", _node_sel_other = "1" })
o:depends({ dns_shunt = "chinadns-ng", _node_sel_shunt = "" })
o:depends({ dns_shunt = "dnsmasq", _node_sel_shunt = "" })
o.write = function(self, section, value)
if value ~= "sing-box" and value ~= "xray" then
m:del(section, "v2ray_dns_mode")
@@ -305,7 +293,7 @@ if api.is_finded("smartdns") then
if has_xray then
o:value("xray", "Xray")
end
o:depends({ dns_shunt = "smartdns", _node_sel_other = "1" })
o:depends({ dns_shunt = "smartdns", _node_sel_shunt = "" })
o.write = function(self, section, value)
if value == "socks" then
m:del(section, "v2ray_dns_mode")
+1 -1
View File
@@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-ssr-plus
PKG_VERSION:=196
PKG_RELEASE:=87
PKG_RELEASE:=88
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
+1 -1
View File
@@ -111,7 +111,7 @@ usage() {
loger() {
# 1.alert 2.crit 3.err 4.warn 5.notice 6.info 7.debug
logger -st ssr-rules[$$] -p$1 $2
logger -st "ssr-rules[$$]" -p "$1" -- "$2"
}
# IP list normalization function (for comparison)
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise
PKG_VERSION:=2026.10.7
PKG_RELEASE:=32
PKG_VERSION:=2026.10.8
PKG_RELEASE:=33
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=nexttrace
PKG_VERSION:=1.7.3
PKG_RELEASE:=16
PKG_VERSION:=1.7.4
PKG_RELEASE:=17
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/nxtrace/NTrace-core/tar.gz/v$(PKG_VERSION)?
+2 -2
View File
@@ -9,7 +9,7 @@ PKG_NAME:=natmapt
PKG_UPSTREAM_VERSION:=20260214
PKG_UPSTREAM_GITHASH:=
PKG_VERSION:=$(PKG_UPSTREAM_VERSION)$(if $(PKG_UPSTREAM_GITHASH),~$(call version_abbrev,$(PKG_UPSTREAM_GITHASH)))
PKG_RELEASE:=11
PKG_RELEASE:=12
SCRIPTS_VERSION:=0.2026.01.24
PKG_SOURCE_SUBDIR:=$(PKG_UPSTREAM_NAME)-$(PKG_UPSTREAM_VERSION)
@@ -23,7 +23,7 @@ PKG_SOURCE:=$(PKG_SOURCE_SUBDIR).tar.xz
else
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/heiher/natmap.git
PKG_SOURCE_VERSION:=f3b9b2e5d2027eb1c2bd95c0afb582c865d16805
PKG_SOURCE_VERSION:=ed69834e8139744abe1b70c341dbd2c3b40ea53e
PKG_MIRROR_HASH:=skip
PKG_SOURCE:=$(PKG_SOURCE_SUBDIR)-$(PKG_SOURCE_VERSION).tar.gz