⛄ Sync 2026-07-31 00:26:24

This commit is contained in:
github-actions[bot] committed 2026-07-31 00:26:24 +08:00
1 parent 47d0743808
commit b44a4364c4
125 files changed
+3954 -140418

No files matched your search

-29
View File
@@ -1,29 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
include $(TOPDIR)/rules.mk
LUCI_TITLE:=The modern ImmortalWrt proxy platform for ARM64/AMD64
LUCI_PKGARCH:=all
LUCI_DEPENDS:= \
+sing-box \
+firewall4 \
+kmod-nft-tproxy \
+ucode-mod-digest \
+ucode-mod-socket \
+ucode-mod-uloop
PKG_NAME:=luci-app-homeproxy
define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy
/etc/homeproxy/certs/
/etc/homeproxy/ruleset/
/etc/homeproxy/resources/direct_list.txt
/etc/homeproxy/resources/proxy_list.txt
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -1,374 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2022-2025 ImmortalWrt.org
*/
'use strict';
'require baseclass';
'require form';
'require fs';
'require rpc';
'require uci';
'require ui';
return baseclass.extend({
dns_strategy: {
'': _('Default'),
'prefer_ipv4': _('Prefer IPv4'),
'prefer_ipv6': _('Prefer IPv6'),
'ipv4_only': _('IPv4 only'),
'ipv6_only': _('IPv6 only')
},
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'
],
tls_cipher_suites: [
'TLS_RSA_WITH_AES_128_CBC_SHA',
'TLS_RSA_WITH_AES_256_CBC_SHA',
'TLS_RSA_WITH_AES_128_GCM_SHA256',
'TLS_RSA_WITH_AES_256_GCM_SHA384',
'TLS_AES_128_GCM_SHA256',
'TLS_AES_256_GCM_SHA384',
'TLS_CHACHA20_POLY1305_SHA256',
'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA',
'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA',
'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA',
'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256'
],
tls_versions: [
'1.0',
'1.1',
'1.2',
'1.3'
],
CBIStaticList: form.DynamicList.extend({
__name__: 'CBI.StaticList',
renderWidget: function(/* ... */) {
let dl = form.DynamicList.prototype.renderWidget.apply(this, arguments);
dl.querySelector('.add-item ul > li[data-value="-"]')?.remove();
return dl;
}
}),
calcStringMD5(e) {
/* Thanks to https://stackoverflow.com/a/41602636 */
let h = (a, b) => {
let c, d, e, f, g;
c = a & 2147483648;
d = b & 2147483648;
e = a & 1073741824;
f = b & 1073741824;
g = (a & 1073741823) + (b & 1073741823);
return e & f ? g ^ 2147483648 ^ c ^ d : e | f ? g & 1073741824 ? g ^ 3221225472 ^ c ^ d : g ^ 1073741824 ^ c ^ d : g ^ c ^ d;
}, k = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b & c | ~b & d, e), g))) << f | a >>> 32 - f, b),
l = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b & d | c & ~d, e), g))) << f | a >>> 32 - f, b),
m = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b ^ c ^ d, e), g))) << f | a >>> 32 - f, b),
n = (a, b, c, d, e, f, g) => h((a = h(a, h(h(c ^ (b | ~d), e), g))) << f | a >>> 32 - f, b),
p = a => { let b = '', d = ''; for (let c = 0; c <= 3; c++) d = a >>> 8 * c & 255, d = '0' + d.toString(16), b += d.substr(d.length - 2, 2); return b; };
let f = [], q, r, s, t, a, b, c, d;
e = (() => {
e = e.replace(/\r\n/g, '\n');
let b = '';
for (let d = 0; d < e.length; d++) {
let c = e.charCodeAt(d);
b += c < 128 ? String.fromCharCode(c) : c < 2048 ? String.fromCharCode(c >> 6 | 192) + String.fromCharCode(c & 63 | 128) :
String.fromCharCode(c >> 12 | 224) + String.fromCharCode(c >> 6 & 63 | 128) + String.fromCharCode(c & 63 | 128);
}
return b;
})();
f = (() => {
let c = e.length, a = c + 8, d = 16 * ((a - a % 64) / 64 + 1), b = Array(d - 1), f = 0, g = 0;
for (; g < c;) a = (g - g % 4) / 4, f = g % 4 * 8, b[a] |= e.charCodeAt(g) << f, g++;
a = (g - g % 4) / 4, b[a] |= 128 << g % 4 * 8, b[d - 2] = c << 3, b[d - 1] = c >>> 29;
return b;
})();
a = 1732584193, b = 4023233417, c = 2562383102, d = 271733878;
for (e = 0; e < f.length; e += 16) {
q = a, r = b, s = c, t = d;
a = k(a, b, c, d, f[e + 0], 7, 3614090360), d = k(d, a, b, c, f[e + 1], 12, 3905402710),
c = k(c, d, a, b, f[e + 2], 17, 606105819), b = k(b, c, d, a, f[e + 3], 22, 3250441966),
a = k(a, b, c, d, f[e + 4], 7, 4118548399), d = k(d, a, b, c, f[e + 5], 12, 1200080426),
c = k(c, d, a, b, f[e + 6], 17, 2821735955), b = k(b, c, d, a, f[e + 7], 22, 4249261313),
a = k(a, b, c, d, f[e + 8], 7, 1770035416), d = k(d, a, b, c, f[e + 9], 12, 2336552879),
c = k(c, d, a, b, f[e + 10], 17, 4294925233), b = k(b, c, d, a, f[e + 11], 22, 2304563134),
a = k(a, b, c, d, f[e + 12], 7, 1804603682), d = k(d, a, b, c, f[e + 13], 12, 4254626195),
c = k(c, d, a, b, f[e + 14], 17, 2792965006), b = k(b, c, d, a, f[e + 15], 22, 1236535329),
a = l(a, b, c, d, f[e + 1], 5, 4129170786), d = l(d, a, b, c, f[e + 6], 9, 3225465664),
c = l(c, d, a, b, f[e + 11], 14, 643717713), b = l(b, c, d, a, f[e + 0], 20, 3921069994),
a = l(a, b, c, d, f[e + 5], 5, 3593408605), d = l(d, a, b, c, f[e + 10], 9, 38016083),
c = l(c, d, a, b, f[e + 15], 14, 3634488961), b = l(b, c, d, a, f[e + 4], 20, 3889429448),
a = l(a, b, c, d, f[e + 9], 5, 568446438), d = l(d, a, b, c, f[e + 14], 9, 3275163606),
c = l(c, d, a, b, f[e + 3], 14, 4107603335), b = l(b, c, d, a, f[e + 8], 20, 1163531501),
a = l(a, b, c, d, f[e + 13], 5, 2850285829), d = l(d, a, b, c, f[e + 2], 9, 4243563512),
c = l(c, d, a, b, f[e + 7], 14, 1735328473), b = l(b, c, d, a, f[e + 12], 20, 2368359562),
a = m(a, b, c, d, f[e + 5], 4, 4294588738), d = m(d, a, b, c, f[e + 8], 11, 2272392833),
c = m(c, d, a, b, f[e + 11], 16, 1839030562), b = m(b, c, d, a, f[e + 14], 23, 4259657740),
a = m(a, b, c, d, f[e + 1], 4, 2763975236), d = m(d, a, b, c, f[e + 4], 11, 1272893353),
c = m(c, d, a, b, f[e + 7], 16, 4139469664), b = m(b, c, d, a, f[e + 10], 23, 3200236656),
a = m(a, b, c, d, f[e + 13], 4, 681279174), d = m(d, a, b, c, f[e + 0], 11, 3936430074),
c = m(c, d, a, b, f[e + 3], 16, 3572445317), b = m(b, c, d, a, f[e + 6], 23, 76029189),
a = m(a, b, c, d, f[e + 9], 4, 3654602809), d = m(d, a, b, c, f[e + 12], 11, 3873151461),
c = m(c, d, a, b, f[e + 15], 16, 530742520), b = m(b, c, d, a, f[e + 2], 23, 3299628645),
a = n(a, b, c, d, f[e + 0], 6, 4096336452), d = n(d, a, b, c, f[e + 7], 10, 1126891415),
c = n(c, d, a, b, f[e + 14], 15, 2878612391), b = n(b, c, d, a, f[e + 5], 21, 4237533241),
a = n(a, b, c, d, f[e + 12], 6, 1700485571), d = n(d, a, b, c, f[e + 3], 10, 2399980690),
c = n(c, d, a, b, f[e + 10], 15, 4293915773), b = n(b, c, d, a, f[e + 1], 21, 2240044497),
a = n(a, b, c, d, f[e + 8], 6, 1873313359), d = n(d, a, b, c, f[e + 15], 10, 4264355552),
c = n(c, d, a, b, f[e + 6], 15, 2734768916), b = n(b, c, d, a, f[e + 13], 21, 1309151649),
a = n(a, b, c, d, f[e + 4], 6, 4149444226), d = n(d, a, b, c, f[e + 11], 10, 3174756917),
c = n(c, d, a, b, f[e + 2], 15, 718787259), b = n(b, c, d, a, f[e + 9], 21, 3951481745),
a = h(a, q), b = h(b, r), c = h(c, s), d = h(d, t);
}
return (p(a) + p(b) + p(c) + p(d)).toLowerCase();
},
decodeBase64Str(str) {
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)
str = str + Array(padding + 1).join('=');
return decodeURIComponent(Array.prototype.map.call(atob(str), (c) =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''));
},
getBuiltinFeatures() {
const callGetSingBoxFeatures = rpc.declare({
object: 'luci.homeproxy',
method: 'singbox_get_features',
expect: { '': {} }
});
return L.resolveDefault(callGetSingBoxFeatures(), {});
},
generateRand(type, length) {
let byteArr;
if (['base64', 'hex'].includes(type))
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)
);
default:
return null;
};
},
loadDefaultLabel(uciconfig, ucisection) {
let label = uci.get(uciconfig, ucisection, 'label');
if (label) {
return label;
} else {
uci.set(uciconfig, ucisection, 'label', ucisection);
return ucisection;
}
},
loadModalTitle(title, addtitle, uciconfig, ucisection) {
let label = uci.get(uciconfig, ucisection, 'label');
return label ? title + ' » ' + label : addtitle;
},
toArray(value) {
if (Array.isArray(value))
return value;
else if (value == null || value === '')
return [];
return [ value ];
},
showConfigDiagnostics(diagnostics) {
let items = diagnostics?.items || [],
generatedAt = null,
noticeColor = '#1f232b',
noticeAccentColor = '#8b1e1e';
document.querySelectorAll('.homeproxy-config-diagnostics').forEach((node) => node.remove());
if (!items.length)
return;
if (diagnostics?.time) {
let date = new Date(Number(diagnostics.time) * 1000);
if (!isNaN(date.getTime()))
generatedAt = date.toLocaleString();
}
ui.addNotification(null, E('div', { 'style': 'color:%s;'.format(noticeColor) }, [
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeColor) }, _('HomeProxy found the following issues. Check and fix them manually:')),
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeAccentColor) },
generatedAt
? _('These diagnostics are from the last configuration generation or subscription update (%s). Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.').format(generatedAt)
: _('These diagnostics are from the last configuration generation or subscription update. Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.')),
E('ul', { 'style': 'color:%s;'.format(noticeColor) }, items.map((item) => E('li', { 'style': 'color:%s;'.format(noticeColor) }, [
E('strong', { 'style': 'color:%s;'.format(noticeColor) }, '[' + (item.type || 'warning') + '] '),
item.message || '',
item.suggestion ? E('div', { 'style': 'margin-top:.2em;color:%s;font-weight:600;'.format(noticeAccentColor) },
_('Suggestion:') + item.suggestion) : ''
])))
]), 'warning', 'homeproxy-config-diagnostics');
},
renderSectionAdd(section, extra_class) {
let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, (v) => {
let button = el.querySelector('.cbi-section-create > .cbi-button-add');
let uciconfig = section.uciconfig || section.map.config;
if (!v) {
button.disabled = true;
return true;
} else if (uci.get(uciconfig, v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique UCI identifier'));
} else {
button.disabled = null;
return true;
}
}, 'blur', 'keyup');
return el;
},
uploadCertificate(_option, type, filename, ev) {
const callWriteCertificate = rpc.declare({
object: 'luci.homeproxy',
method: 'certificate_write',
params: ['filename'],
expect: { '': {} }
});
return ui.uploadFile('/tmp/homeproxy_certificate.tmp', ev.target)
.then(L.bind((_btn, res) => {
return L.resolveDefault(callWriteCertificate(filename), {}).then((ret) => {
if (ret.result === true)
ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(type, res.size)));
else
ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(type, ret.error)));
});
}, this, ev.target))
.catch((e) => { ui.addNotification(null, E('p', e.message)) });
},
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));
return true;
},
validateCertificatePath(section_id, value) {
if (section_id && value)
if (!value.match(/^(\/etc\/homeproxy\/certs\/|\/etc\/acme\/|\/etc\/ssl\/).+$/))
return _('Expecting: %s').format(_('/etc/homeproxy/certs/..., /etc/acme/..., /etc/ssl/...'));
return true;
},
validatePortRange(section_id, value) {
if (section_id && value) {
value = value.match(/^(\d+)?\:(\d+)?$/);
if (value && (value[1] || value[2])) {
if (!value[1])
value[1] = 0;
else if (!value[2])
value[2] = 65535;
if (value[1] < value[2] && value[2] <= 65535)
return true;
}
return _('Expecting: %s').format( _('valid port range (port1:port2)'));
}
return true;
},
validateUniqueValue(uciconfig, ucisection, ucioption, section_id, value) {
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
if (ucioption === 'node' && ['urltest', 'selector'].includes(value))
return true;
let duplicate = false;
uci.sections(uciconfig, ucisection, (res) => {
if (res['.name'] !== section_id)
if (res[ucioption] === value)
duplicate = true
});
if (duplicate)
return _('Expecting: %s').format(_('unique value'));
}
return true;
},
validateUUID(section_id, value) {
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
else if (value.match('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') === null)
return _('Expecting: %s').format(_('valid uuid'));
}
return true;
}
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,887 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2022-2025 ImmortalWrt.org
*/
'use strict';
'require form';
'require poll';
'require rpc';
'require uci';
'require ui';
'require view';
'require homeproxy as hp';
'require tools.widgets as widgets';
const callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: ['name'],
expect: { '': {} }
});
const CBIGenValue = form.Value.extend({
__name__: 'CBI.GenValue',
renderWidget(/* ... */) {
let node = form.Value.prototype.renderWidget.apply(this, arguments);
if (!this.password)
node.classList.add('control-group');
(node.querySelector('.control-group') || node).appendChild(E('button', {
class: 'cbi-button cbi-button-add',
title: _('Generate'),
click: ui.createHandlerFn(this, handleGenKey, this.hp_options || this.option)
}, [ _('Generate') ]));
return node;
}
});
function getServiceStatus() {
return L.resolveDefault(callServiceList('homeproxy'), {}).then((res) => {
let isRunning = false;
try {
isRunning = res['homeproxy']['instances']['sing-box-s']['running'];
} catch (e) { }
return isRunning;
});
}
function renderStatus(isRunning, version) {
let spanTemp = '<em><span style="color:%s"><strong>%s (sing-box v%s) %s</strong></span></em>';
let renderHTML;
if (isRunning)
renderHTML = spanTemp.format('green', _('HomeProxy Server'), version, _('RUNNING'));
else
renderHTML = spanTemp.format('red', _('HomeProxy Server'), version, _('NOT RUNNING'));
return renderHTML;
}
function handleGenKey(option) {
let section_id = this.section.section;
let type = this.section.getOption('type')?.formvalue(section_id);
let widget = L.bind((option) => {
return this.map.findElement('id', 'widget.' + this.cbid(section_id).replace(/\.[^\.]+$/, '.') + option);
}, this);
const callSingBoxGenerator = rpc.declare({
object: 'luci.homeproxy',
method: 'singbox_generator',
params: ['type', 'params'],
expect: { '': {} }
});
if (typeof option === 'object') {
return callSingBoxGenerator(option.type, option.params).then((res) => {
if (res.result)
option.callback.call(this, res.result).forEach(([k, v]) => {
widget(k).value = v ?? '';
});
else
ui.addNotification(null, E('p', _('Failed to generate %s, error: %s.').format(type, res.error)));
});
} else {
let password, required_method;
if (option === 'uuid')
required_method = 'uuid';
else if (type === 'shadowsocks')
required_method = this.section.getOption('shadowsocks_encrypt_method')?.formvalue(section_id);
switch (required_method) {
case 'none':
password = '';
break;
case 'uuid':
password = hp.generateRand('uuid');
break;
default:
password = hp.generateRand('hex', 16);
break;
}
/* AEAD */
((length) => {
if (length && length > 0)
password = hp.generateRand('base64', length);
})(hp.shadowsocks_encrypt_length[required_method]);
return widget(option).value = password;
}
}
return view.extend({
load() {
return Promise.all([
uci.load('homeproxy'),
hp.getBuiltinFeatures()
]);
},
render(data) {
let m, s, o;
let features = data[1];
m = new form.Map('homeproxy', _('HomeProxy Server'),
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
s = m.section(form.TypedSection);
s.render = function() {
poll.add(() => {
return L.resolveDefault(getServiceStatus()).then((res) => {
let view = document.getElementById('service_status');
view.innerHTML = renderStatus(res, features.version);
});
});
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
E('p', { id: 'service_status' }, _('Collecting data...'))
]);
}
s = m.section(form.NamedSection, 'server', 'homeproxy', _('Global settings'));
o = s.option(form.Flag, 'enabled', _('Enable'));
o.rmempty = false;
s = m.section(form.GridSection, 'server', _('Server settings'));
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
s.modaltitle = L.bind(hp.loadModalTitle, this, _('Server'), _('Add a server'), data[0]);
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
s.renderSectionAdd = L.bind(hp.renderSectionAdd, this, s);
o = s.option(form.Value, 'label', _('Label'));
o.load = L.bind(hp.loadDefaultLabel, this, data[0]);
o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'server', 'label');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'enabled', _('Enable'));
o.default = o.enabled;
o.rmempty = false;
o.editable = true;
o = s.option(form.Flag, 'firewall', _('Firewall'),
_('Allow access from the Internet.'));
o.editable = true;
o = s.option(form.ListValue, 'type', _('Type'));
o.value('anytls', _('AnyTLS'));
o.value('http', _('HTTP'));
if (features.with_quic) {
o.value('hysteria', _('Hysteria'));
o.value('hysteria2', _('Hysteria2'));
o.value('naive', _('NaïveProxy'));
}
o.value('mixed', _('Mixed'));
o.value('shadowsocks', _('Shadowsocks'));
o.value('socks', _('Socks'));
o.value('trojan', _('Trojan'));
if (features.with_quic)
o.value('tuic', _('Tuic'));
o.value('vless', _('VLESS'));
o.value('vmess', _('VMess'));
o.rmempty = false;
o = s.option(form.Value, 'address', _('Listen address'));
o.placeholder = '::';
o.datatype = 'ipaddr';
o.modalonly = true;
o = s.option(form.Value, 'port', _('Listen port'),
_('The port must be unique.'));
o.datatype = 'port';
o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'server', 'port');
o = s.option(form.Value, 'username', _('Username'));
o.depends('type', 'http');
o.depends('type', 'mixed');
o.depends('type', 'naive');
o.depends('type', 'socks');
o.modalonly = true;
o = s.option(CBIGenValue, 'password', _('Password'));
o.password = true;
o.depends('type', 'anytls');
o.depends({'type': /^(http|mixed|naive|socks)$/, 'username': /[\s\S]/});
o.depends('type', 'hysteria2');
o.depends('type', 'shadowsocks');
o.depends('type', 'trojan');
o.depends('type', 'tuic');
o.validate = function(section_id, value) {
if (section_id) {
let type = this.section.formvalue(section_id, 'type');
let required_type = [ 'anytls', 'http', 'mixed', 'naive', 'socks', 'shadowsocks', 'trojan' ];
if (required_type.includes(type)) {
if (type === 'shadowsocks') {
let encmode = this.section.formvalue(section_id, 'shadowsocks_encrypt_method');
if (encmode === 'none')
return true;
else if (encmode === '2022-blake3-aes-128-gcm')
return hp.validateBase64Key(24, section_id, value);
else if (['2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305'].includes(encmode))
return hp.validateBase64Key(44, section_id, value);
}
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
}
}
return true;
}
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;
o.modalonly = true;
o = s.option(form.Value, 'hysteria_down_mbps', _('Max download speed'),
_('Max download speed in Mbps.'));
o.datatype = 'uinteger';
o.depends('type', 'hysteria');
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_up_mbps', _('Max upload speed'),
_('Max upload speed in Mbps.'));
o.datatype = 'uinteger';
o.depends('type', 'hysteria');
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.option(form.ListValue, 'hysteria_auth_type', _('Authentication type'));
o.value('', _('Disable'));
o.value('base64', _('Base64'));
o.value('string', _('String'));
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_auth_payload', _('Authentication payload'));
o.password = true;
o.depends({'type': 'hysteria', 'hysteria_auth_type': /[\s\S]/});
o.rmempty = false;
o.modalonly = true;
o = s.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
o.value('', _('Disable'));
o.value('salamander', _('Salamander'));
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.option(CBIGenValue, 'hysteria_obfs_password', _('Obfuscate password'));
o.password = true;
o.depends('type', 'hysteria');
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': /[\s\S]/});
o.modalonly = true;
o = s.option(form.Value, 'hysteria_recv_window_conn', _('QUIC stream receive window'),
_('The QUIC stream-level flow control window for receiving data.'));
o.datatype = 'uinteger';
o.default = '67108864';
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_recv_window_client', _('QUIC connection receive window'),
_('The QUIC connection-level flow control window for receiving data.'));
o.datatype = 'uinteger';
o.default = '15728640';
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_max_conn_client', _('QUIC maximum concurrent bidirectional streams'),
_('The maximum number of QUIC concurrent bidirectional streams that a peer is allowed to open.'));
o.datatype = 'uinteger';
o.default = '1024';
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Flag, 'hysteria_disable_mtu_discovery', _('Disable Path MTU discovery'),
_('Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size.'));
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
o.depends({'type': 'hysteria2', 'hysteria_down_mbps': '', 'hysteria_up_mbps': ''});
o.modalonly = true;
o = s.option(form.Value, 'hysteria_masquerade', _('Masquerade'),
_('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 */
/* Shadowsocks config */
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
for (let i of hp.shadowsocks_encrypt_methods)
o.value(i);
o.default = 'aes-128-gcm';
o.depends('type', 'shadowsocks');
o.modalonly = true;
/* Tuic config start */
o = s.option(CBIGenValue, 'uuid', _('UUID'));
o.password = true;
o.depends('type', 'tuic');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.validate = hp.validateUUID;
o.modalonly = true;
o = s.option(form.ListValue, 'tuic_congestion_control', _('Congestion control algorithm'),
_('QUIC congestion control algorithm.'));
o.value('cubic');
o.value('new_reno');
o.value('bbr');
o.default = 'cubic';
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.Value, 'tuic_auth_timeout', _('Auth timeout'),
_('How long the server should wait for the client to send the authentication command (in seconds).'));
o.datatype = 'uinteger';
o.default = '3';
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.Flag, 'tuic_enable_zero_rtt', _('Enable 0-RTT handshake'),
_('Enable 0-RTT QUIC connection handshake on the client side. This is not impacting much on the performance, as the protocol is fully multiplexed.<br/>' +
'Disabling this is highly recommended, as it is vulnerable to replay attacks.'));
o.depends('type', 'tuic');
o.modalonly = true;
o = s.option(form.Value, 'tuic_heartbeat', _('Heartbeat interval'),
_('Interval for sending heartbeat packets for keeping the connection alive (in seconds).'));
o.datatype = 'uinteger';
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');
o.depends('type', 'vless');
o.modalonly = true;
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
o.datatype = 'uinteger';
o.depends('type', 'vmess');
o.modalonly = true;
/* VMess config end */
/* Transport config start */
o = s.option(form.ListValue, 'transport', _('Transport'),
_('No TCP transport, plain HTTP is merged into the HTTP transport.'));
o.value('', _('None'));
o.value('grpc', _('gRPC'));
o.value('http', _('HTTP'));
o.value('httpupgrade', _('HTTPUpgrade'));
o.value('quic', _('QUIC'));
o.value('ws', _('WebSocket'));
o.depends('type', 'trojan');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.onchange = function(ev, section_id, value) {
let desc = this.map.findElement('id', 'cbid.homeproxy.%s.transport'.format(section_id)).nextElementSibling;
if (value === 'http')
desc.innerHTML = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.');
else if (value === 'quic')
desc.innerHTML = _('No additional encryption support: It\'s basically duplicate encryption.');
else
desc.innerHTML = _('No TCP transport, plain HTTP is merged into the HTTP transport.');
let tls_element = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
if ((value === 'http' && tls_element.checked) || (value === 'grpc' && !features.with_grpc))
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
_('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.');
else if (value === 'grpc' && features.with_grpc)
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
_('If the transport doesn\'t see any activity after a duration of this time (in seconds), it pings the client to check if the connection is still active.');
}
o.modalonly = true;
/* gRPC config start */
o = s.option(form.Value, 'grpc_servicename', _('gRPC service name'));
o.depends('transport', 'grpc');
o.modalonly = true;
/* gRPC config end */
/* HTTP(Upgrade) config start */
o = s.option(form.DynamicList, 'http_host', _('Host'));
o.datatype = 'hostname';
o.depends('transport', 'http');
o.modalonly = true;
o = s.option(form.Value, 'httpupgrade_host', _('Host'));
o.datatype = 'hostname';
o.depends('transport', 'httpupgrade');
o.modalonly = true;
o = s.option(form.Value, 'http_path', _('Path'));
o.depends('transport', 'http');
o.depends('transport', 'httpupgrade');
o.modalonly = true;
o = s.option(form.Value, 'http_method', _('Method'));
o.depends('transport', 'http');
o.modalonly = true;
o = s.option(form.Value, 'http_idle_timeout', _('Idle timeout'),
_('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.'));
o.datatype = 'uinteger';
o.depends('transport', 'grpc');
o.depends({'transport': 'http', 'tls': '1'});
o.modalonly = true;
if (features.with_grpc) {
o = s.option(form.Value, 'http_ping_timeout', _('Ping timeout'),
_('The timeout (in seconds) that after performing a keepalive check, the client will wait for activity. If no activity is detected, the connection will be closed.'));
o.datatype = 'uinteger';
o.depends('transport', 'grpc');
o.modalonly = true;
}
/* HTTP config end */
/* WebSocket config start */
o = s.option(form.Value, 'ws_host', _('Host'));
o.depends('transport', 'ws');
o.modalonly = true;
o = s.option(form.Value, 'ws_path', _('Path'));
o.depends('transport', 'ws');
o.modalonly = true;
o = s.option(form.Value, 'websocket_early_data', _('Early data'),
_('Allowed payload size is in the request.'));
o.datatype = 'uinteger';
o.value('2048');
o.depends('transport', 'ws');
o.modalonly = true;
o = s.option(form.Value, 'websocket_early_data_header', _('Early data header name'),
_('Early data is sent in path instead of header by default.') +
'<br/>' +
_('To be compatible with Xray-core, set this to <code>Sec-WebSocket-Protocol</code>.'));
o.value('Sec-WebSocket-Protocol');
o.depends('transport', 'ws');
o.modalonly = true;
/* WebSocket config end */
/* Transport config end */
/* Mux config start */
o = s.option(form.Flag, 'multiplex', _('Multiplex'));
o.depends('type', 'shadowsocks');
o.depends('type', 'trojan');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.modalonly = true;
o = s.option(form.Flag, 'multiplex_padding', _('Enable padding'));
o.depends('multiplex', '1');
o.modalonly = true;
if (features.hp_has_tcp_brutal) {
o = s.option(form.Flag, 'multiplex_brutal', _('Enable TCP Brutal'),
_('Enable TCP Brutal congestion control algorithm'));
o.depends('multiplex', '1');
o.modalonly = true;
o = s.option(form.Value, 'multiplex_brutal_down', _('Download bandwidth'),
_('Download bandwidth in Mbps.'));
o.datatype = 'uinteger';
o.depends('multiplex_brutal', '1');
o.modalonly = true;
o = s.option(form.Value, 'multiplex_brutal_up', _('Upload bandwidth'),
_('Upload bandwidth in Mbps.'));
o.datatype = 'uinteger';
o.depends('multiplex_brutal', '1');
o.modalonly = true;
}
/* Mux config end */
/* TLS config start */
o = s.option(form.Flag, 'tls', _('TLS'));
o.depends('type', 'anytls');
o.depends('type', 'http');
o.depends('type', 'hysteria');
o.depends('type', 'hysteria2');
o.depends('type', 'naive');
o.depends('type', 'trojan');
o.depends('type', 'tuic');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id) {
let type = this.map.lookupOption('type', section_id)[0].formvalue(section_id);
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
if (['hysteria', 'hysteria2', 'tuic'].includes(type)) {
tls.checked = true;
tls.disabled = true;
} else {
tls.disabled = null;
}
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'tls_sni', _('TLS SNI'),
_('Used to verify the hostname on the returned certificates unless insecure is given.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'),
_('List of supported application level protocols, in order of preference.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'),
_('The minimum TLS version that is acceptable.'));
o.value('', _('default'));
for (let i of hp.tls_versions)
o.value(i);
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'),
_('The maximum TLS version that is acceptable.'));
o.value('', _('default'));
for (let i of hp.tls_versions)
o.value(i);
o.depends('tls', '1');
o.modalonly = true;
o = s.option(hp.CBIStaticList, 'tls_cipher_suites', _('Cipher suites'),
_('The elliptic curves that will be used in an ECDHE handshake, in preference order. If empty, the default will be used.'));
for (let i of hp.tls_cipher_suites)
o.value(i);
o.depends('tls', '1');
o.optional = true;
o.modalonly = true;
if (features.with_acme) {
o = s.option(form.Flag, 'tls_acme', _('Enable ACME'),
_('Use ACME TLS certificate issuer.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_acme_domain', _('Domains'));
o.datatype = 'hostname';
o.depends('tls_acme', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_dsn', _('Default server name'),
_('Server name to use when choosing a certificate if the ClientHello\'s ServerName field is empty.'));
o.depends('tls_acme', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_email', _('Email'),
_('The email address to use when creating or selecting an existing ACME server account.'));
o.depends('tls_acme', '1');
o.validate = function(section_id, value) {
if (section_id) {
if (!value)
return _('Expecting: %s').format('non-empty value');
else if (!value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
return _('Expecting: %s').format('valid email address');
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_provider', _('CA provider'),
_('The ACME CA provider to use.'));
o.value('letsencrypt', _('Let\'s Encrypt'));
o.value('zerossl', _('ZeroSSL'));
o.depends('tls_acme', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'tls_dns01_challenge', _('DNS01 challenge'))
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.ListValue, 'tls_dns01_provider', _('DNS provider'));
o.value('alidns', _('Alibaba Cloud DNS'));
o.value('cloudflare', _('Cloudflare'));
o.depends('tls_dns01_challenge', '1');
o.default = 'cloudflare';
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_ali_akid', _('Access key ID'));
o.password = true;
o.depends('tls_dns01_provider', 'alidns');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_ali_aksec', _('Access key secret'));
o.password = true;
o.depends('tls_dns01_provider', 'alidns');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_ali_rid', _('Region ID'));
o.depends('tls_dns01_provider', 'alidns');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_cf_api_token', _('API token'));
o.password = true;
o.depends('tls_dns01_provider', 'cloudflare');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'tls_acme_dhc', _('Disable HTTP challenge'));
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Flag, 'tls_acme_dtac', _('Disable TLS ALPN challenge'));
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_ahp', _('Alternative HTTP port'),
_('The alternate port to use for the ACME HTTP challenge; if non-empty, this port will be used instead of 80 to spin up a listener for the HTTP challenge.'));
o.datatype = 'port';
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_atp', _('Alternative TLS port'),
_('The alternate port to use for the ACME TLS-ALPN challenge; the system must forward 443 to this port for challenge to succeed.'));
o.datatype = 'port';
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Flag, 'tls_acme_external_account', _('External Account Binding'),
_('EAB (External Account Binding) contains information necessary to bind or map an ACME account to some other account known by the CA.' +
'<br/>External account bindings are "used to associate an ACME account with an existing account in a non-ACME system, such as a CA customer database.'));
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_ea_keyid', _('External account key ID'));
o.password = true;
o.depends('tls_acme_external_account', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_ea_mackey', _('External account MAC key'));
o.password = true;
o.depends('tls_acme_external_account', '1');
o.rmempty = false;
o.modalonly = true;
}
o = s.option(form.Flag, 'tls_reality', _('REALITY'));
o.depends({'tls': '1', 'tls_acme': '0', 'type': /^(anytls|vless)$/});
o.depends({'tls': '1', 'tls_acme': null, 'type': /^(anytls|vless)$/});
o.modalonly = true;
o = s.option(CBIGenValue, 'tls_reality_private_key', _('REALITY private key'));
o.password = true;
o.hp_options = {
type: 'reality-keypair',
params: '',
callback: function(result) {
return [
[this.option, result.private_key],
['tls_reality_public_key', result.public_key]
]
}
}
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key'));
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_reality_short_id', _('REALITY short ID'));
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_max_time_difference', _('Max time difference'),
_('The maximum time difference between the server and the client.'));
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_server_addr', _('Handshake server address'));
o.datatype = 'hostname';
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_server_port', _('Handshake server port'));
o.datatype = 'port';
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
_('The server public key, in PEM format.'));
o.value('/etc/homeproxy/certs/server_publickey.pem');
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': null});
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': null});
o.validate = hp.validateCertificatePath;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({'tls': '1', 'tls_cert_path': '/etc/homeproxy/certs/server_publickey.pem'});
o.onclick = L.bind(hp.uploadCertificate, this, _('certificate'), 'server_publickey');
o.modalonly = true;
o = s.option(form.Value, 'tls_key_path', _('Key path'),
_('The server private key, in PEM format.'));
o.value('/etc/homeproxy/certs/server_privatekey.pem');
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': null});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': null});
o.validate = hp.validateCertificatePath;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Button, '_upload_key', _('Upload key'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({'tls': '1', 'tls_key_path': '/etc/homeproxy/certs/server_privatekey.pem'});
o.onclick = L.bind(hp.uploadCertificate, this, _('private key'), 'server_privatekey');
o.modalonly = true;
o = s.option(form.TextValue, 'tls_ech_key', _('ECH key'));
o.placeholder = '-----BEGIN ECH KEYS-----\nACBE2+piYBLrOywCbRYU+ZpEkk8keeBlUXbKqLRmQ/68FwBL/g0ARwAAIAAgn8HI\n93RfdV/LaDk+LC9H4h+4WhVBFmWKdhiT3vvpGi8ACAABAAEAAQADABRvdXRlci1z\nbmkuYW55LmRvbWFpbgAA\n-----END ECH KEYS-----';
o.monospace = true;
o.cols = 30
o.rows = 3;
o.hp_options = {
type: 'ech-keypair',
params: '',
callback: function(result) {
return [
[this.option, result.ech_key],
['tls_ech_config', result.ech_cfg]
]
}
}
o.renderWidget = function(section_id, option_index, cfgvalue) {
let node = form.TextValue.prototype.renderWidget.apply(this, arguments);
const cbid = this.cbid(section_id) + '._outer_sni';
node.appendChild(E('div', { 'class': 'control-group' }, [
E('input', {
id: cbid,
class: 'cbi-input-text',
style: 'width: 10em',
placeholder: 'outer-sni.any.domain'
}),
E('button', {
class: 'cbi-button cbi-button-add',
click: ui.createHandlerFn(this, () => {
this.hp_options.params = document.getElementById(cbid).value;
return handleGenKey.call(this, this.hp_options);
})
}, [ _('Generate') ])
]));
return node;
}
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.TextValue, 'tls_ech_config', _('ECH config'));
o.placeholder = '-----BEGIN ECH CONFIGS-----\nAEv+DQBHAAAgACCfwcj3dF91X8toOT4sL0fiH7haFUEWZYp2GJPe++kaLwAIAAEA\nAQABAAMAFG91dGVyLXNuaS5hbnkuZG9tYWluAAA=\n-----END ECH CONFIGS-----';
o.monospace = true;
o.cols = 30
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});
o.modalonly = true;
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
o.depends({'network': 'udp', '!reverse': true});
o.modalonly = true;
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
_('Enable UDP fragmentation.'));
o.depends({'network': 'tcp', '!reverse': true});
o.modalonly = true;
o = s.option(form.Value, 'udp_timeout', _('UDP NAT expiration time'),
_('In seconds.'));
o.datatype = 'uinteger';
o.placeholder = '300';
o.depends({'network': 'tcp', '!reverse': true});
o.modalonly = true;
o = s.option(form.ListValue, 'network', _('Network'));
o.value('tcp', _('TCP'));
o.value('udp', _('UDP'));
o.value('', _('Both'));
o.depends('type', 'naive');
o.depends('type', 'shadowsocks');
o.modalonly = true;
o = s.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
_('The network interface to bind to.'));
o.multiple = false;
o.noaliases = true;
o.modalonly = true;
o = s.option(form.Flag, 'reuse_addr', _('Reuse address'),
_('Reuse listener address.'));
o.modalonly = true;
/* Extra settings end */
return m.render();
}
});
@@ -1,296 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2022-2025 ImmortalWrt.org
*/
'use strict';
'require dom';
'require form';
'require fs';
'require poll';
'require rpc';
'require uci';
'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; \
} \
.description { \
background-color: #33ccff; \
}';
const hp_dir = '/var/run/homeproxy';
function getConnStat(o, site) {
const callConnStat = rpc.declare({
object: 'luci.homeproxy',
method: 'connection_check',
params: ['site'],
expect: { '': {} }
});
o.default = E('div', { 'style': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callConnStat(site), {}).then((ret) => {
let ele = o.default.firstElementChild.nextElementSibling;
if (ret.result) {
ele.style.setProperty('color', 'green');
ele.innerHTML = _('passed');
} else {
ele.style.setProperty('color', 'red');
ele.innerHTML = _('failed');
}
});
})
}, [ _('Check') ]),
' ',
E('strong', { 'style': 'color:gray' }, _('unchecked')),
]);
}
function getResVersion(o, type) {
const callResVersion = rpc.declare({
object: 'luci.homeproxy',
method: 'resources_get_version',
params: ['type'],
expect: { '': {} }
});
const callResUpdate = rpc.declare({
object: 'luci.homeproxy',
method: 'resources_update',
params: ['type'],
expect: { '': {} }
});
return L.resolveDefault(callResVersion(type), {}).then((res) => {
let spanTemp = E('div', { 'style': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callResUpdate(type), {}).then((res) => {
switch (res.status) {
case 0:
o.description = _('Successfully updated.');
break;
case 1:
o.description = _('Update failed.');
break;
case 2:
o.description = _('Already in updating.');
break;
case 3:
o.description = _('Already at the latest version.');
break;
default:
o.description = _('Unknown error.');
break;
}
return o.map.reset();
});
})
}, [ _('Check update') ]),
' ',
E('strong', { 'style': (res.error ? 'color:red' : 'color:green') },
[ res.error ? 'not found' : res.version ]
),
]);
o.default = spanTemp;
});
}
function getRuntimeLog(o, name, _option_index, section_id, _in_table) {
const filename = o.option.split('_')[1];
let section, log_level_el;
switch (filename) {
case 'homeproxy':
section = null;
break;
case 'sing-box-c':
section = 'config';
break;
case 'sing-box-s':
section = 'server';
break;
}
if (section) {
const selected = uci.get('homeproxy', section, 'log_level') || 'warn';
const choices = {
trace: _('Trace'),
debug: _('Debug'),
info: _('Info'),
warn: _('Warn'),
error: _('Error'),
fatal: _('Fatal'),
panic: _('Panic')
};
log_level_el = E('select', {
'id': o.cbid(section_id),
'class': 'cbi-input-select',
'style': 'margin-left: 4px; width: 6em;',
'change': ui.createHandlerFn(this, (ev) => {
uci.set('homeproxy', section, 'log_level', ev.target.value);
return o.map.save(null, true).then(() => {
ui.changes.apply(true);
});
})
});
Object.keys(choices).forEach((v) => {
log_level_el.appendChild(E('option', {
'value': v,
'selected': (v === selected) ? '' : null
}, [ choices[v] ]));
});
}
const callLogClean = rpc.declare({
object: 'luci.homeproxy',
method: 'log_clean',
params: ['type'],
expect: { '': {} }
});
const log_textarea = E('div', { 'id': 'log_textarea' },
E('img', {
'src': L.resource('icons/loading.svg'),
'alt': _('Loading'),
'style': 'vertical-align:middle'
}, _('Collecting data...'))
);
let log;
poll.add(L.bind(() => {
return fs.read_direct(String.format('%s/%s.log', hp_dir, filename), 'text')
.then((res) => {
log = E('pre', { 'wrap': 'pre' }, [
res.trim() || _('Log is empty.')
]);
dom.content(log_textarea, log);
}).catch((err) => {
if (err.toString().includes('NotFoundError'))
log = E('pre', { 'wrap': 'pre' }, [
_('Log file does not exist.')
]);
else
log = E('pre', { 'wrap': 'pre' }, [
_('Unknown error: %s').format(err)
]);
dom.content(log_textarea, log);
});
}));
return E([
E('style', [ css ]),
E('div', {'class': 'cbi-map'}, [
E('h3', {'name': 'content', 'style': 'align-items: center; display: flex;'}, [
_('%s log').format(name),
log_level_el || '',
E('button', {
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-left: 4px;',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callLogClean(filename), {});
})
}, [ _('Clean log') ])
]),
E('div', {'class': 'cbi-section'}, [
log_textarea,
E('div', {'style': 'text-align:right'},
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
)
])
])
]);
}
return view.extend({
render() {
let m, s, o;
m = new form.Map('homeproxy');
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
s.anonymous = true;
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
o = s.option(form.DummyValue, '_check_google', _('Google'));
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
s.anonymous = true;
o = s.option(form.DummyValue, '_china_ip4_version', _('China IPv4 list version'));
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip4');
o.rawhtml = true;
o = s.option(form.DummyValue, '_china_ip6_version', _('China IPv6 list version'));
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip6');
o.rawhtml = true;
o = s.option(form.DummyValue, '_china_list_version', _('China list version'));
o.cfgvalue = L.bind(getResVersion, this, o, 'china_list');
o.rawhtml = true;
o = s.option(form.DummyValue, '_gfw_list_version', _('GFW list version'));
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
o.rawhtml = true;
o = s.option(form.Value, 'github_token', _('GitHub token'));
o.password = true;
o.renderWidget = function() {
let node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
'class': 'cbi-button cbi-button-apply',
'title': _('Save'),
'click': ui.createHandlerFn(this, () => {
return this.map.save(null, true).then(() => {
ui.changes.apply(true);
});
}, this.option)
}, [ _('Save') ]));
return node;
}
s = m.section(form.NamedSection, 'config', 'homeproxy');
s.anonymous = true;
o = s.option(form.DummyValue, '_homeproxy_logview');
o.render = L.bind(getRuntimeLog, this, o, _('HomeProxy'));
o = s.option(form.DummyValue, '_sing-box-c_logview');
o.render = L.bind(getRuntimeLog, this, o, _('sing-box client'));
o = s.option(form.DummyValue, '_sing-box-s_logview');
o.render = L.bind(getRuntimeLog, this, o, _('sing-box server'));
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,32 +0,0 @@
{
"bounding": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SYS_PTRACE"
],
"effective": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SYS_PTRACE"
],
"ambient": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SYS_PTRACE"
],
"permitted": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SYS_PTRACE"
],
"inheritable": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SYS_PTRACE"
]
}
-87
View File
@@ -1,87 +0,0 @@
config homeproxy 'infra'
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
option mixed_port '5330'
option redirect_port '5331'
option tproxy_port '5332'
option dns_port '5333'
option dns_redirect '1'
option ntp_server 'nil'
option sniff_override '1'
option udp_timeout ''
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option table_mark '100'
option self_mark '100'
option tproxy_mark '101'
option tun_mark '102'
config homeproxy 'migration'
option crontab '1'
config homeproxy 'config'
option main_node 'nil'
option main_udp_node 'same'
option dns_server '8.8.8.8'
option china_dns_server '223.5.5.5'
option routing_mode 'bypass_mainland_china'
option routing_port 'common'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option github_token ''
option log_level 'warn'
option clash_api_enabled '1'
option clash_api_external_controller '127.0.0.1:9090'
option clash_api_default_mode 'Rule'
list clash_api_allow_origin 'https://metacubex.github.io'
list clash_api_allow_origin 'https://metacubexd.pages.dev'
list clash_api_allow_origin 'http://d.metacubex.one'
list clash_api_allow_origin 'https://yacd.metacubex.one'
option clash_api_allow_private_network '1'
option metacubexd_url 'https://metacubexd.pages.dev/#/overview'
config homeproxy 'control'
option lan_proxy_mode 'disabled'
list wan_proxy_ipv4_ips '91.105.192.0/23'
list wan_proxy_ipv4_ips '91.108.4.0/22'
list wan_proxy_ipv4_ips '91.108.8.0/22'
list wan_proxy_ipv4_ips '91.108.16.0/22'
list wan_proxy_ipv4_ips '91.108.12.0/22'
list wan_proxy_ipv4_ips '91.108.20.0/22'
list wan_proxy_ipv4_ips '91.108.56.0/22'
list wan_proxy_ipv4_ips '149.154.160.0/20'
list wan_proxy_ipv4_ips '185.76.151.0/24'
list wan_proxy_ipv4_ips '203.208.50.66/32'
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
list wan_proxy_ipv6_ips '2a0a:f280::/32'
config homeproxy 'routing'
option sniff_override '1'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy 'dns'
option dns_strategy 'prefer_ipv4'
option default_server 'local-dns'
option disable_cache '0'
option disable_cache_expire '0'
config homeproxy 'subscription'
option auto_update '0'
option allow_insecure '0'
option allow_unsupported_tls_pin_fallback '0'
option packet_encoding 'xudp'
option update_via_proxy '0'
option filter_nodes 'blacklist'
list filter_keywords '重置|到期|过期|剩余|套餐'
list filter_keywords 'Expiration|Remaining'
config homeproxy 'server'
option enabled '0'
option log_level 'warn'
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
20260704060757
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
20260704060757
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
202607052256
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
202607052256
@@ -1,19 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
NAME="homeproxy"
log_max_size="50" #KB
main_log_file="/var/run/$NAME/$NAME.log"
singc_log_file="/var/run/$NAME/sing-box-c.log"
sings_log_file="/var/run/$NAME/sing-box-s.log"
while true; do
sleep 180
for i in "$main_log_file" "$singc_log_file" "$sings_log_file"; do
[ -s "$i" ] || continue
[ "$(( $(ls -l "$i" | awk -F ' ' '{print $5}') / 1024 >= log_max_size))" -eq "0" ] || echo "" > "$i"
done
done
@@ -1,673 +0,0 @@
#!/usr/bin/utpl -S
{%-
import { readfile } from 'fs';
import { cursor } from 'uci';
import { isEmpty } from '/etc/homeproxy/scripts/homeproxy.uc';
const fw4 = require('fw4');
function array_to_nftarr(array) {
if (type(array) !== 'array')
return null;
return `{ ${join(', ', uniq(array))} }`;
}
function resolve_ipv6(str) {
if (isEmpty(str))
return null;
let ipv6 = fw4.parse_subnet(str)?.[0];
if (!ipv6 || ipv6.family !== 6)
return null;
if (ipv6.bits > -1)
return `${ipv6.addr}/${ipv6.bits}`;
else
return `& ${ipv6.mask} == ${ipv6.addr}`;
}
function resolve_mark(str) {
if (isEmpty(str))
return null;
let mark = fw4.parse_mark(str);
if (isEmpty(mark))
return null;
if (mark.mask === 0xffffffff)
return fw4.hex(mark.mark);
else if (mark.mark === 0)
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)}`;
else if (mark.mark === mark.mask)
return `mark or ${fw4.hex(mark.mark)}`;
else if (mark.mask === 0)
return `mark xor ${fw4.hex(mark.mark)}`;
else
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)} xor ${fw4.hex(mark.mark)}`;
}
/* Misc config */
const resources_dir = '/etc/homeproxy/resources';
/* UCI config start */
const cfgname = 'homeproxy';
const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
let outbound_node, outbound_udp_node, default_outbound, china_dns_server, bypass_cn_traffic;
default_outbound = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
if (routing_mode !== 'custom') {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
if (isEmpty(outbound_node))
outbound_node = default_outbound;
} else {
outbound_node = default_outbound;
}
const use_default_outbound_routing = (routing_mode !== 'custom') &&
isEmpty(uci.get(cfgname, 'config', 'main_node') || 'nil') &&
!isEmpty(default_outbound);
let routing_port = uci.get(cfgname, 'config', 'routing_port');
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy',
ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0';
let self_mark, redirect_port,
tproxy_port, tproxy_mark,
tun_name, tun_mark;
if (match(proxy_mode, /redirect/)) {
self_mark = uci.get(cfgname, 'infra', 'self_mark') || '100';
redirect_port = uci.get(cfgname, 'infra', 'redirect_port') || '5331';
}
if (match(proxy_mode, /tproxy/))
if (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing) {
tproxy_port = uci.get(cfgname, 'infra', 'tproxy_port') || '5332';
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101');
}
if (match(proxy_mode, /tun/)) {
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102');
}
const control_options = [
"listen_interfaces", "lan_proxy_mode",
"lan_direct_mac_addrs", "lan_direct_ipv4_ips", "lan_direct_ipv6_ips",
"lan_proxy_mac_addrs", "lan_proxy_ipv4_ips", "lan_proxy_ipv6_ips",
"lan_gaming_mode_mac_addrs", "lan_gaming_mode_ipv4_ips", "lan_gaming_mode_ipv6_ips",
"lan_global_proxy_mac_addrs", "lan_global_proxy_ipv4_ips", "lan_global_proxy_ipv6_ips",
"wan_proxy_ipv4_ips", "wan_proxy_ipv6_ips",
"wan_direct_ipv4_ips", "wan_direct_ipv6_ips"
];
const control_info = {};
for (let i in control_options)
control_info[i] = uci.get(cfgname, 'control', i);
const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1';
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
dns_port = uci.get('dhcp', '@dnsmasq[0]', 'port') || '53';
/* UCI config end */
-%}
{# Reserved addresses -#}
set homeproxy_local_addr_v4 {
type ipv4_addr
flags interval
auto-merge
elements = {
0.0.0.0/8,
10.0.0.0/8,
100.64.0.0/10,
127.0.0.0/8,
169.254.0.0/16,
172.16.0.0/12,
192.0.0.0/24,
192.0.2.0/24,
192.31.196.0/24,
192.52.193.0/24,
192.88.99.0/24,
192.168.0.0/16,
192.175.48.0/24,
198.18.0.0/15,
198.51.100.0/24,
203.0.113.0/24,
224.0.0.0/4,
240.0.0.0/4
}
}
{% if (ipv6_support === '1'): %}
set homeproxy_local_addr_v6 {
type ipv6_addr
flags interval
auto-merge
elements = {
::/128,
::1/128,
::ffff:0:0/96,
100::/64,
64:ff9b::/96,
2001::/32,
2001:10::/28,
2001:20::/28,
2001:db8::/28,
2002::/16,
fc00::/7,
fe80::/10,
ff00::/8
}
}
{% endif %}
{% if (routing_mode === 'gfwlist'): %}
set homeproxy_gfw_list_v4 {
type ipv4_addr
flags interval
auto-merge
}
{% if (ipv6_support === '1'): %}
set homeproxy_gfw_list_v6 {
type ipv6_addr
flags interval
auto-merge
}
{% endif /* ipv6_support */ %}
{% elif (match(routing_mode, /mainland_china/) || bypass_cn_traffic === '1'): %}
set homeproxy_mainland_addr_v4 {
type ipv4_addr
flags interval
auto-merge
elements = {
{% for (let cnip4 in split(trim(readfile(resources_dir + '/china_ip4.txt')), /[\r\n]/)): %}
{{ cnip4 }},
{% endfor %}
}
}
{% if ((ipv6_support === '1') || china_dns_server): %}
set homeproxy_mainland_addr_v6 {
type ipv6_addr
flags interval
auto-merge
elements = {
{% for (let cnip6 in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)): %}
{{ cnip6 }},
{% endfor %}
}
}
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{# WAN ACL addresses #}
set homeproxy_wan_proxy_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (control_info.wan_proxy_ipv4_ips): %}
elements = { {{ join(', ', control_info.wan_proxy_ipv4_ips) }} }
{% endif %}
}
{% if (ipv6_support === '1'): %}
set homeproxy_wan_proxy_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (control_info.wan_proxy_ipv6_ips): %}
elements = { {{ join(', ', control_info.wan_proxy_ipv6_ips) }} }
{% endif /* wan_proxy_ipv6_ips*/ %}
}
{% endif /* ipv6_support */ %}
set homeproxy_wan_direct_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (control_info.wan_direct_ipv4_ips): %}
elements = { {{ join(', ', control_info.wan_direct_ipv4_ips) }} }
{% endif %}
}
{% if (ipv6_support === '1'): %}
set homeproxy_wan_direct_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (control_info.wan_direct_ipv6_ips): %}
elements = { {{ join(', ', control_info.wan_direct_ipv6_ips) }} }
{% endif /* wan_direct_ipv6_ips */ %}
}
{% endif /* ipv6_support */ %}
{% if (routing_port): %}
set homeproxy_routing_port {
type inet_service
flags interval
auto-merge
elements = { {{ join(', ', split(routing_port, ',')) }} }
}
{% endif %}
{# DNS hijack & TCP redirect #}
chain dstnat {
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
{% if (control_info.listen_interfaces): %}
meta iifname {{ array_to_nftarr(control_info.listen_interfaces) }}
{%- endif /* listen_interfaces */ %}
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dns_port }} comment "!{{ cfgname }}: DNS hijack"
{% endif /* dns_hijacked */ %}
{% if (match(proxy_mode, /redirect/)): %}
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
{% endif /* proxy_mode */ %}
}
{# TCP redirect #}
{% if (match(proxy_mode, /redirect/)): %}
chain homeproxy_redirect_proxy {
meta l4proto tcp counter redirect to :{{ redirect_port }}
}
chain homeproxy_redirect_proxy_port {
{% if (routing_port): %}
tcp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_redirect_proxy
}
chain homeproxy_redirect_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(control_info.listen_interfaces) }} counter return
{% endif %}
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
{% endif /* lan_proxy_mode */ %}
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
counter goto homeproxy_redirect
{% endif %}
}
chain homeproxy_redirect {
meta mark {{ self_mark }} counter return
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect_proxy_port
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode === 'gfwlist'): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect_proxy
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_mac_addrs */ %}
counter goto homeproxy_redirect_proxy_port
}
chain homeproxy_output_redir {
type nat hook output priority filter -105; policy accept
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect
}
{% endif %}
{# UDP tproxy #}
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing)): %}
chain homeproxy_mangle_tproxy {
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
{% if (ipv6_support === '1'): %}
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip6 to [::1]:{{ tproxy_port }} counter accept
{% endif %}
}
chain homeproxy_mangle_tproxy_port {
{% if (routing_port): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_mangle_tproxy
}
chain homeproxy_mangle_mark {
{% if (routing_port): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
meta l4proto udp meta mark set {{ tproxy_mark }} counter accept
}
chain homeproxy_mangle_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(uniq([...control_info.listen_interfaces, ...['lo']])) }} counter return
{% endif %}
meta iifname != lo udp dport 53 counter return
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_prerouting
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
{% endif /* lan_proxy_mode */ %}
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
counter goto homeproxy_mangle_prerouting
{% endif %}
}
chain homeproxy_mangle_prerouting {
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tproxy_port
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode === 'gfwlist'): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tproxy
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_mac_addrs */ %}
counter goto homeproxy_mangle_tproxy_port
}
chain homeproxy_mangle_output {
meta mark {{ self_mark }} counter return
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode === 'gfwlist'): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
counter goto homeproxy_mangle_mark
}
chain mangle_prerouting {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac
}
chain mangle_output {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output
}
{% endif %}
{# TUN #}
{% if (match(proxy_mode, /tun/)): %}
chain homeproxy_mangle_lanac {
iifname {{ tun_name }} counter return
udp dport 53 counter return
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(control_info.listen_interfaces) }} counter return
{% endif %}
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tun
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
{% endif /* lan_proxy_mode */ %}
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
counter goto homeproxy_mangle_tun
{% endif %}
}
chain homeproxy_mangle_tun_mark {
{% if (routing_port): %}
{% if (proxy_mode === 'tun'): %}
tcp dport != @homeproxy_routing_port counter return
{% endif /* proxy_mode */ %}
udp dport != @homeproxy_routing_port counter return
{% endif /* routing_port */ %}
counter meta mark set {{ tun_mark }}
}
chain homeproxy_mangle_tun {
iifname {{ tun_name }} counter return
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tun_mark
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
{% if (control_info.wan_direct_ipv4_ips): %}
ip daddr {{ array_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
{% endif /* wan_direct_ipv4_ips */ %}
{% if (control_info.wan_direct_ipv6_ips): %}
ip6 daddr {{ array_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return
{% endif /* wan_direct_ipv6_ips */ %}
{% if (routing_mode === 'gfwlist'): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter meta mark set {{ tun_mark }}
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_mac_addrs */ %}
counter goto homeproxy_mangle_tun_mark
}
chain mangle_prerouting {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac
}
chain mangle_output {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun
}
{% endif %}
@@ -1,50 +0,0 @@
#!/usr/bin/ucode
'use strict';
import { writefile } from 'fs';
import { cursor } from 'uci';
import { isEmpty, RUN_DIR } from 'homeproxy';
const cfgname = 'homeproxy';
const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china',
proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy';
let outbound_node, tun_name;
if (match(proxy_mode, /tun/)) {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node === 'nil')
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
if (outbound_node !== 'nil')
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
}
const server_enabled = uci.get(cfgname, 'server', 'enabled');
let forward = [],
input = [];
if (tun_name) {
push(forward, `oifname ${tun_name} counter accept comment "!${cfgname}: accept tun forward"`);
push(input ,`iifname ${tun_name} counter accept comment "!${cfgname}: accept tun input"`);
}
if (server_enabled === '1') {
uci.foreach(cfgname, 'server', (s) => {
if (s.enabled !== '1' || s.firewall !== '1')
return;
let proto = s.network || '{ tcp, udp }';
push(input, `meta l4proto ${proto} th dport ${s.port} counter accept comment "!${cfgname}: accept server ${s['.name']}"`);
});
}
if (!isEmpty(forward))
writefile(RUN_DIR + '/fw4_forward.nft', join('\n', forward) + '\n');
if (!isEmpty(input))
writefile(RUN_DIR + '/fw4_input.nft', join('\n', input) + '\n');
File diff suppressed because it is too large. Load diff
@@ -1,185 +0,0 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
'use strict';
import { writefile } from 'fs';
import { cursor } from 'uci';
import {
isEmpty, strToBool, strToInt, strToTime,
removeBlankAttrs, HP_DIR, RUN_DIR
} from 'homeproxy';
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
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,
output: RUN_DIR + '/sing-box-s.log',
timestamp: true
};
config.inbounds = [];
uci.foreach(uciconfig, uciserver, (cfg) => {
if (cfg.enabled !== '1')
return;
push(config.inbounds, {
type: cfg.type,
tag: 'cfg-' + cfg['.name'] + '-in',
listen: cfg.address || '::',
listen_port: strToInt(cfg.port),
bind_interface: cfg.bind_interface,
reuse_addr: strToBool(cfg.reuse_addr),
tcp_fast_open: strToBool(cfg.tcp_fast_open),
tcp_multi_path: strToBool(cfg.tcp_multi_path),
udp_fragment: strToBool(cfg.udp_fragment),
udp_timeout: strToTime(cfg.udp_timeout),
network: cfg.network,
/* AnyTLS */
padding_scheme: cfg.anytls_padding_scheme,
/* Hysteria */
up_mbps: strToInt(cfg.hysteria_up_mbps),
down_mbps: strToInt(cfg.hysteria_down_mbps),
obfs: cfg.hysteria_obfs_type ? {
type: cfg.hysteria_obfs_type,
password: cfg.hysteria_obfs_password
} : cfg.hysteria_obfs_password,
recv_window_conn: strToInt(cfg.hysteria_recv_window_conn),
recv_window_client: strToInt(cfg.hysteria_revc_window_client),
max_conn_client: strToInt(cfg.hysteria_max_conn_client),
disable_mtu_discovery: strToBool(cfg.hysteria_disable_mtu_discovery),
ignore_client_bandwidth: strToBool(cfg.hysteria_ignore_client_bandwidth),
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)
}
] : null,
multiplex: (cfg.multiplex === '1') ? {
enabled: true,
padding: strToBool(cfg.multiplex_padding),
brutal: (cfg.multiplex_brutal === '1') ? {
enabled: true,
up_mbps: strToInt(cfg.multiplex_brutal_up),
down_mbps: strToInt(cfg.multiplex_brutal_down)
} : null
} : null,
tls: (cfg.tls === '1') ? {
enabled: true,
server_name: cfg.tls_sni,
alpn: cfg.tls_alpn,
min_version: cfg.tls_min_version,
max_version: cfg.tls_max_version,
cipher_suites: cfg.tls_cipher_suites,
certificate_path: cfg.tls_cert_path,
key_path: cfg.tls_key_path,
acme: (cfg.tls_acme === '1') ? {
domain: cfg.tls_acme_domain,
data_directory: HP_DIR + '/certs',
default_server_name: cfg.tls_acme_dsn,
email: cfg.tls_acme_email,
provider: cfg.tls_acme_provider,
disable_http_challenge: strToBool(cfg.tls_acme_dhc),
disable_tls_alpn_challenge: (cfg.tls_acme_dtac),
alternative_http_port: strToInt(cfg.tls_acme_ahp),
alternative_tls_port: strToInt(cfg.tls_acme_atp),
external_account: (cfg.tls_acme_external_account === '1') ? {
key_id: cfg.tls_acme_ea_keyid,
mac_key: cfg.tls_acme_ea_mackey
} : null,
dns01_challenge: (cfg.tls_dns01_challenge === '1') ? {
provider: cfg.tls_dns01_provider,
access_key_id: cfg.tls_dns01_ali_akid,
access_key_secret: cfg.tls_dns01_ali_aksec,
region_id: cfg.tls_dns01_ali_rid,
api_token: cfg.tls_dns01_cf_api_token
} : null
} : null,
ech: (cfg.tls_ech_key) ? {
enabled: true,
key: split(cfg.tls_ech_key, '\n'),
// config: split(cfg.tls_ech_config, '\n')
} : null,
reality: (cfg.tls_reality === '1') ? {
enabled: true,
private_key: cfg.tls_reality_private_key,
short_id: cfg.tls_reality_short_id,
max_time_difference: strToTime(cfg.tls_reality_max_time_difference),
handshake: {
server: cfg.tls_reality_server_addr,
server_port: strToInt(cfg.tls_reality_server_port)
}
} : null
} : null,
transport: !isEmpty(cfg.transport) ? {
type: cfg.transport,
host: cfg.http_host || cfg.httpupgrade_host,
path: cfg.http_path || cfg.ws_path,
headers: cfg.ws_host ? {
Host: cfg.ws_host
} : null,
method: cfg.http_method,
max_early_data: strToInt(cfg.websocket_early_data),
early_data_header_name: cfg.websocket_early_data_header,
service_name: cfg.grpc_servicename,
idle_timeout: strToTime(cfg.http_idle_timeout),
ping_timeout: strToTime(cfg.http_ping_timeout)
} : null
});
});
if (length(config.inbounds) === 0)
exit(1);
system('mkdir -p ' + RUN_DIR);
writefile(RUN_DIR + '/sing-box-s.json', sprintf('%.J\n', removeBlankAttrs(config)));
@@ -1,252 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
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, "'", "'\\''")}'`;
};
export function isBinary(str) {
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
if (byte <= 8 || (byte >= 14 && byte <= 31))
return true;
return false;
};
export function executeCommand(...args) {
let outfd = mkstemp();
let errfd = mkstemp();
const exitcode = system(`${join(' ', args)} >&${outfd.fileno()} 2>&${errfd.fileno()}`);
outfd.seek(0);
errfd.seek(0);
const stdout = outfd.read(1024 * 512) ?? '';
const stderr = errfd.read(1024 * 512) ?? '';
outfd.close();
errfd.close();
const binary = isBinary(stdout);
return {
command: join(' ', args),
stdout: binary ? null : stdout,
stderr,
exitcode,
binary
};
};
export function getTime(epoch) {
const local_time = localtime(epoch);
return replace(replace(sprintf(
'%d-%2d-%2d@%2d:%2d:%2d',
local_time.year,
local_time.mon,
local_time.mday,
local_time.hour,
local_time.min,
local_time.sec
), ' ', '0'), '@', ' ');
};
export function wGET(url, ua) {
const result = wGETResult(url, ua);
return result?.stdout;
};
export function wGETResult(url, ua) {
if (!url || type(url) !== 'string')
return null;
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
let last_result = null;
for (let extra in [ '', '-4' ]) {
const output = executeCommand(`/usr/bin/wget -q ${extra} -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
output.stdout = trim(output.stdout);
output.stderr = trim(output.stderr);
last_result = output;
if (output.exitcode === 0 && output.stdout)
return output;
}
if (last_result) {
last_result.stdout = trim(last_result.stdout);
last_result.stderr = trim(last_result.stderr);
}
return last_result;
};
/* Utilities end */
/* String helper start */
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
export function strToBool(str) {
return (str === '1') || null;
};
export function strToInt(str) {
return !isEmpty(str) ? (int(str) || null) : null;
};
export function strToTime(str) {
return !isEmpty(str) ? (str + 's') : null;
};
export function removeBlankAttrs(res) {
let content;
if (type(res) === 'object') {
content = {};
map(keys(res), (k) => {
if (type(res[k]) in ['array', 'object'])
content[k] = removeBlankAttrs(res[k]);
else if (res[k] !== null && res[k] !== '')
content[k] = res[k];
});
} else if (type(res) === 'array') {
content = [];
map(res, (k, i) => {
if (type(k) in ['array', 'object'])
push(content, removeBlankAttrs(k));
else if (k !== null && k !== '')
push(content, k);
});
} else
return res;
return content;
};
export function validateHostname(hostname) {
return (match(hostname, /^[a-zA-Z0-9_]+$/) != null ||
(match(hostname, /^[a-zA-Z0-9_][a-zA-Z0-9_%-.]*[a-zA-Z0-9]$/) &&
match(hostname, /[^0-9.]/)));
};
export function validation(datatype, data) {
if (!datatype || !data)
return null;
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
return (ret === 0);
};
/* String helper end */
/* String parser start */
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
str = trim(str);
str = replace(str, '_', '/');
str = replace(str, '-', '+');
const padding = length(str) % 4;
if (padding)
str = str + substr('====', padding);
return b64dec(str);
};
export function parseURL(url) {
if (type(url) !== 'string')
return null;
const services = {
http: '80',
https: '443'
};
const objurl = {};
objurl.href = url;
url = replace(url, /#(.+)$/, (_, val) => {
objurl.hash = val;
return '';
});
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
objurl.protocol = val;
return '';
});
url = replace(url, /\?(.+)/, (_, val) => {
objurl.search = val;
objurl.searchParams = urldecode_params(val);
return '';
});
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
val = replace(val, /^([^@]+)@/, (_, val) => {
objurl.userinfo = val;
return '';
});
val = replace(val, /:(\d+)$/, (_, val) => {
objurl.port = val;
return '';
});
if (validation('ip4addr', val) ||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
validation('hostname', val))
objurl.hostname = val;
return '';
});
objurl.pathname = url || '/';
if (!objurl.protocol || !objurl.hostname)
return null;
if (objurl.userinfo) {
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
objurl.password = val;
return '';
});
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
objurl.username = objurl.userinfo;
delete objurl.userinfo;
} else {
delete objurl.userinfo;
delete objurl.password;
}
};
if (!objurl.port)
objurl.port = services[objurl.protocol];
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
objurl.origin = `${objurl.protocol}://${objurl.host}`;
return objurl;
};
/* String parser end */
@@ -1,349 +0,0 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*/
'use strict';
import { cursor } from 'uci';
import { executeCommand, isEmpty, parseURL, validation } from 'homeproxy';
import {
format_controller,
is_portable_bind_host,
local_ipv4_addresses,
normalize_local_controller_option,
parse_controller
} from '/etc/homeproxy/scripts/clash_api.uc';
const uci = cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
const uciinfra = 'infra',
ucimigration = 'migration',
ucimain = 'config',
ucinode = 'node',
ucidns = 'dns',
ucidnsserver = 'dns_server',
ucidnsrule = 'dns_rule',
ucirouting = 'routing',
uciroutingnode = 'routing_node',
uciroutingrule = 'routing_rule',
ucisubscription = 'subscription',
uciserver = 'server';
/* Generate a random 256-bit hex secret for the Clash API. Returns null when no
* usable randomness source is available, in which case the caller leaves the
* secret unset rather than installing a predictable value. */
function generateClashSecret() {
let secret = trim(executeCommand("head -c 32 /dev/urandom | hexdump -v -e '1/1 \"%02x\"'")?.stdout || '');
if (!match(secret, /^[a-f0-9]{64}$/))
secret = trim(executeCommand('tr -dc a-f0-9 < /dev/urandom | head -c 64')?.stdout || '');
return match(secret, /^[a-f0-9]{32,}$/) ? secret : null;
}
function normalizeLocalControllerOptions() {
let local_addresses = local_ipv4_addresses((command) => executeCommand(command)?.stdout || ''),
fallback_host = local_addresses[0];
if (isEmpty(fallback_host))
return;
/* clash_api_external_controller is intentionally pinned to loopback (see
* below) and must NOT be rewritten to the LAN address. Only the LAN-facing
* display proxy is bound to a reachable local address here. */
for (let item in [
[ 'clash_api_proxy_external_controller', 9091, true ]
])
normalize_local_controller_option(uci, uciconfig, ucimain,
local_addresses, fallback_host, item[0], item[1], item[2]);
}
/* 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]);
} else {
if (china_dns_server === 'wan_114')
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
else if (match(china_dns_server, /,/))
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');
if (isEmpty(uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback')))
uci.set(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback', '0');
/* Clash API dashboard integration */
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_enabled')))
uci.set(uciconfig, ucimain, 'clash_api_enabled', '1');
/* Bind sing-box's own Clash API controller to loopback so it is never
* reachable directly from the LAN; dashboards reach it only through the
* filtering display proxy. Existing LAN-bound controllers are migrated to
* 127.0.0.1 once (port preserved); a later deliberate change is left alone. */
const clash_external = uci.get(uciconfig, ucimain, 'clash_api_external_controller');
if (isEmpty(clash_external)) {
uci.set(uciconfig, ucimain, 'clash_api_external_controller', '127.0.0.1:9090');
} else if (isEmpty(uci.get(uciconfig, ucimigration, 'clash_api_localhost'))) {
const clash_controller = parse_controller(clash_external, 9090);
if (!is_portable_bind_host(clash_controller.host))
uci.set(uciconfig, ucimain, 'clash_api_external_controller',
format_controller('127.0.0.1', clash_controller.port || 9090));
}
uci.set(uciconfig, ucimigration, 'clash_api_localhost', '1');
/* sing-box's Clash API performs NO authentication when the secret is empty,
* and the LAN-facing display proxy forwards to it, so anyone able to reach the
* proxy could otherwise control the proxy without credentials. Generate a
* random secret when none is configured. */
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_secret'))) {
const clash_secret = generateClashSecret();
if (!isEmpty(clash_secret))
uci.set(uciconfig, ucimain, 'clash_api_secret', clash_secret);
}
normalizeLocalControllerOptions();
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_default_mode')))
uci.set(uciconfig, ucimain, 'clash_api_default_mode', 'Rule');
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_origin'))) {
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubex.github.io');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubexd.pages.dev');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'http://d.metacubex.one');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://yacd.metacubex.one');
}
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_private_network')))
uci.set(uciconfig, ucimain, 'clash_api_allow_private_network', '1');
if (isEmpty(uci.get(uciconfig, ucimain, 'metacubexd_url')))
uci.set(uciconfig, ucimain, 'metacubexd_url', 'https://metacubexd.pages.dev/#/overview');
/* 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');
uci.set(uciconfig, '_migration_dns_final_block', 'mode', 'default');
uci.set(uciconfig, '_migration_dns_final_block', 'action', 'reject');
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) {
case 'success':
dns_server_migration[cfg['.name']].rcode = 'NOERROR';
break;
case 'format_error':
dns_server_migration[cfg['.name']].rcode = 'FORMERR';
break;
case 'server_failure':
dns_server_migration[cfg['.name']].rcode = 'SERVFAIL';
break;
case 'name_error':
dns_server_migration[cfg['.name']].rcode = 'NXDOMAIN';
break;
case 'not_implemented':
dns_server_migration[cfg['.name']].rcode = 'NOTIMP';
break;
case 'refused':
default:
dns_server_migration[cfg['.name']].rcode = 'REFUSED';
break;
}
uci.delete(uciconfig, cfg['.name']);
return;
}
uci.set(uciconfig, cfg['.name'], 'type', addr.protocol);
uci.set(uciconfig, cfg['.name'], 'server', addr.hostname);
uci.set(uciconfig, cfg['.name'], 'server_port', addr.port);
uci.set(uciconfig, cfg['.name'], 'path', (addr.pathname !== '/') ? addr.pathname : null);
uci.delete(uciconfig, cfg['.name'], 'address');
}
if (cfg.strategy) {
if (cfg['.name'] === default_dns_server)
uci.set(uciconfig, ucidns, 'default_strategy', cfg.strategy);
dns_server_migration[cfg['.name']] = { strategy: cfg.strategy };
uci.delete(uciconfig, cfg['.name'], 'strategy');
}
if (cfg.client_subnet) {
if (cfg['.name'] === default_dns_server)
uci.set(uciconfig, ucidns, 'client_subnet', cfg.client_subnet);
if (isEmpty(dns_server_migration[cfg['.name']]))
dns_server_migration[cfg['.name']] = {};
dns_server_migration[cfg['.name']].client_subnet = cfg.client_subnet;
uci.delete(uciconfig, cfg['.name'], 'client_subnet');
}
});
/* 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)
return;
map(cfg.outbound, (outbound) => {
switch (outbound) {
case 'direct-out':
case 'block-out':
break;
case 'any-out':
uci.set(uciconfig, ucirouting, 'default_outbound_dns', cfg.server);
break;
default:
uci.set(uciconfig, cfg.outbound, 'domain_resolver', cfg.server);
break;
}
});
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);
if (dns_server_migration[cfg.server].client_subnet)
uci.set(uciconfig, cfg['.name'], 'client_subnet', dns_server_migration[cfg.server].client_subnet);
if (dns_server_migration[cfg.server].rcode) {
uci.set(uciconfig, cfg['.name'], 'action', 'predefined');
uci.set(uciconfig, cfg['.name'], 'rcode', dns_server_migration[cfg.server].rcode);
uci.delete(uciconfig, cfg['.name'], 'server');
}
}
});
/* 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');
});
/* 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');
});
if (!isEmpty(uci.changes(uciconfig)))
uci.commit(uciconfig);
@@ -1,12 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2023 ImmortalWrt.org
SCRIPTS_DIR="/etc/homeproxy/scripts"
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
"$SCRIPTS_DIR"/update_resources.sh "$i"
done
"$SCRIPTS_DIR"/update_subscriptions.uc
@@ -1,87 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2025 ImmortalWrt.org
NAME="homeproxy"
RESOURCES_DIR="/etc/$NAME/resources"
mkdir -p "$RESOURCES_DIR"
RUN_DIR="/var/run/$NAME"
LOG_PATH="$RUN_DIR/$NAME.log"
mkdir -p "$RUN_DIR"
log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") $*" >> "$LOG_PATH"
}
to_upper() {
echo -e "$1" | tr "[a-z]" "[A-Z]"
}
check_list_update() {
local listtype="$1"
local listrepo="$2"
local listref="$3"
local listname="$4"
local lock="$RUN_DIR/update_resources-$listtype.lock"
local github_token="$(uci -q get homeproxy.config.github_token)"
local wget="wget --timeout=10 -q"
exec 200>"$lock"
if ! flock -n 200 &> "/dev/null"; then
log "[$(to_upper "$listtype")] A task is already running."
return 2
fi
[ -z "$github_token" ] || github_token="--header=Authorization: Bearer $github_token"
local list_info="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
local list_sha="$(echo -e "$list_info" | jsonfilter -qe "@[0].sha")"
local list_ver="$(echo -e "$list_info" | jsonfilter -qe "@[0].commit.message" | grep -Eo "[0-9-]+" | tr -d '-')"
if [ -z "$list_sha" ] || [ -z "$list_ver" ]; then
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
return 1
fi
local local_list_ver="$(cat "$RESOURCES_DIR/$listtype.ver" 2>"/dev/null" || echo "NOT FOUND")"
if [ "$local_list_ver" = "$list_ver" ]; then
log "[$(to_upper "$listtype")] Current version: $list_ver."
log "[$(to_upper "$listtype")] You're already at the latest version."
return 3
else
log "[$(to_upper "$listtype")] Local version: $local_list_ver, latest version: $list_ver."
fi
if ! $wget "https://fastly.jsdelivr.net/gh/$listrepo@$list_sha/$listname" -O "$RUN_DIR/$listname" || [ ! -s "$RUN_DIR/$listname" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Update failed."
return 1
fi
mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"
echo -e "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
log "[$(to_upper "$listtype")] Successfully updated."
return 0
}
case "$1" in
"china_ip4")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
;;
"china_ip6")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
;;
"gfw_list")
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
;;
"china_list")
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
sed -i -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt"
;;
*)
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
exit 1
;;
esac
@@ -1,897 +0,0 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
'use strict';
import { md5 } from 'digest';
import { open, writefile } from 'fs';
import { connect } from 'ubus';
import { cursor } from 'uci';
import { urldecode, urlencode } from 'luci.http';
import { init_action } from 'luci.sys';
import { remove_subscription_nodes } from '/etc/homeproxy/scripts/node_references.uc';
import { parse_surge_subscription } from '/etc/homeproxy/scripts/subscription_parsers.uc';
import {
wGETResult, decodeBase64Str, getTime, isEmpty, parseURL,
validation, HP_DIR, RUN_DIR
} from 'homeproxy';
/* UCI config start */
const uci = cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
const ucimain = 'config',
ucinode = 'node',
ucisubscription = 'subscription';
const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0',
allow_unsupported_tls_pin_fallback = uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback') || '0',
filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled',
filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [],
packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp',
subscription_urls = uci.get(uciconfig, ucisubscription, 'subscription_url') || [],
user_agent = uci.get(uciconfig, ucisubscription, 'user_agent'),
via_proxy = uci.get(uciconfig, ucisubscription, 'update_via_proxy') || '0';
const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainalnd_china';
let main_node, main_udp_node;
if (routing_mode !== 'custom') {
main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil';
main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil';
}
/* UCI config end */
/* String helper start */
function filter_check(name) {
if (isEmpty(name) || filter_mode === 'disabled' || isEmpty(filter_keywords))
return false;
let ret = false;
for (let i in filter_keywords) {
const patten = regexp(i);
if (match(name, patten))
ret = true;
}
if (filter_mode === 'whitelist')
ret = !ret;
return ret;
}
/* String helper end */
/* Common var start */
const node_cache = {},
node_result = [];
const ubus = connect();
const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {};
/* Common var end */
const SUBSCRIPTION_DIAGNOSTICS_PATH = RUN_DIR + '/subscription-diagnostics.json';
const SUBSCRIPTION_UPDATE_STATUS_PATH = RUN_DIR + '/subscription-update-status.json';
let subscription_diagnostics = [];
/* Log */
system(`mkdir -p ${RUN_DIR}`);
function log(...args) {
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`);
logfile.close();
}
function reportSubscriptionDiagnostic(type, message, suggestion) {
push(subscription_diagnostics, {
type,
source: 'subscription',
message,
suggestion
});
}
function writeSubscriptionDiagnostics() {
system(`mkdir -p ${RUN_DIR}`);
if (length(subscription_diagnostics)) {
writefile(SUBSCRIPTION_DIAGNOSTICS_PATH, sprintf('%.J\n', {
time: time(),
items: subscription_diagnostics
}));
} else {
system(`rm -f ${SUBSCRIPTION_DIAGNOSTICS_PATH}`);
}
}
function writeSubscriptionUpdateStatus(running, completed, update_result, error) {
system(`mkdir -p ${RUN_DIR}`);
writefile(SUBSCRIPTION_UPDATE_STATUS_PATH, sprintf('%.J\n', {
time: time(),
running: !!running,
completed: !!completed,
update_result,
error: error || null
}));
}
function sanitizeCommandError(text) {
text = trim(text || '');
if (isEmpty(text))
return null;
text = split(text, '\n')[0] || text;
return text;
}
function uci_value_equal(a, b) {
if (type(a) == 'array')
a = map(a, (v) => sprintf('%s', v));
else
a = sprintf('%s', a);
if (type(b) == 'array')
b = map(b, (v) => sprintf('%s', v));
else
b = sprintf('%s', b);
return sprintf('%J', a) == sprintf('%J', b);
}
function setOrDeleteList(section, option, values) {
if (length(values))
uci.set(uciconfig, section, option, values);
else
uci.delete(uciconfig, section, option);
}
function logCleanupChange(change) {
if (!change || !change.node_id)
return;
let target = sprintf('%s.%s', change.section || '-', change.option || '-'),
action = change.action || 'update',
value = (action === 'set') ? sprintf(' to %s', change.value) : '';
log(sprintf('Cleaned reference to deleted subscription node %s: %s %s%s.',
change.node_id, target, action, value));
}
function version_at_least(version, major, minor, patch) {
const m = match(version || '', /^([0-9]+)\.([0-9]+)\.([0-9]+)/);
if (!m)
return false;
const current = [ int(m[1]), int(m[2]), int(m[3]) ],
required = [ major, minor, patch ];
for (let i = 0; i < 3; i++) {
if (current[i] > required[i])
return true;
if (current[i] < required[i])
return false;
}
return true;
}
function tls_cert_pin_unsupported() {
return !version_at_least(sing_features.version, 1, 13, 0);
}
function apply_tls_cert_pin_policy(config, label, pin) {
if (!pin || !tls_cert_pin_unsupported())
return;
const node_label = label || config.label || 'NULL',
version = sing_features.version || 'unknown';
if (config.tls_insecure === '1')
return;
if (allow_unsupported_tls_pin_fallback === '1' || config.type === 'hysteria2') {
config.tls_insecure = '1';
log(sprintf('Node %s uses server certificate fingerprint, but sing-box %s cannot express it; enabling explicit TLS insecure compatibility fallback.',
node_label,
version));
reportSubscriptionDiagnostic('warning',
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256;已降级为 TLS insecure 继续兼容。', node_label, version),
(config.type === 'hysteria2')
? '这是延续旧行为的兼容回退;如需更严格校验,请升级到支持 certificate_public_key_sha256 的 sing-box'
: '若安全要求较高,请关闭“Allow unsupported certificate pin fallback”,让此类节点默认跳过');
return;
}
config.__skip_reason = sprintf('该节点使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', version);
reportSubscriptionDiagnostic('warning',
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', node_label, version),
'升级到支持 certificate_public_key_sha256 的 sing-box,或显式开启兼容 fallback 后重新更新订阅');
log(sprintf('Skipping node %s: server certificate fingerprint is unsupported by sing-box %s.',
node_label,
version));
}
function parse_uri(uri) {
let config, url, params;
if (type(uri) === 'object') {
if (uri.nodetype === 'sip008') {
/* https://shadowsocks.org/guide/sip008.html */
config = {
label: uri.remarks,
type: 'shadowsocks',
address: uri.server,
port: uri.server_port,
shadowsocks_encrypt_method: uri.method,
password: uri.password,
shadowsocks_plugin: uri.plugin,
shadowsocks_plugin_opts: uri.plugin_opts
};
}
} else if (type(uri) === 'string') {
uri = split(trim(uri), '://');
switch (uri[0]) {
case 'anytls':
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'anytls',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
tls: '1',
tls_sni: params.sni,
tls_insecure: (params.insecure === '1') ? '1' : '0'
};
break;
case 'http':
case 'https':
url = parseURL('http://' + uri[1]) || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'http',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
tls: (uri[0] === 'https') ? '1' : '0'
};
break;
case 'hysteria':
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
if (!sing_features.with_quic || (params.protocol && params.protocol !== 'udp')) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!sing_features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria',
address: url.hostname,
port: url.port,
hysteria_protocol: params.protocol || 'udp',
hysteria_auth_type: params.auth ? 'string' : null,
hysteria_auth_payload: params.auth,
hysteria_obfs_password: params.obfsParam,
hysteria_down_mbps: params.downmbps,
hysteria_up_mbps: params.upmbps,
tls: '1',
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
tls_sni: params.peer,
tls_alpn: params.alpn
};
break;
case 'hysteria2':
case 'hy2':
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
if (!sing_features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria2',
address: url.hostname,
port: url.port,
password: url.username ? (
urldecode(url.username + (url.password ? (':' + url.password) : ''))
) : null,
hysteria_obfs_type: params.obfs,
hysteria_obfs_password: params['obfs-password'],
tls: '1',
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
apply_tls_cert_pin_policy(config, config.label, params.pinSHA256);
break;
case 'socks':
case 'socks4':
case 'socks4a':
case 'socsk5':
case 'socks5h':
url = parseURL('http://' + uri[1]) || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'socks',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
socks_version: (match(uri[0], /4/)) ? '4' : '5'
};
break;
case 'ss':
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
if (length(ss_suri) === 2)
ss_slabel = '#' + urlencode(ss_suri[1]);
if (decodeBase64Str(ss_suri[0]))
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = {};
if (url.username && url.password)
/* User info encoded with URIComponent */
ss_userinfo = [url.username, urldecode(url.password)];
else if (url.username)
/* User info encoded with base64 */
ss_userinfo = split(decodeBase64Str(urldecode(url.username)), ':', 2);
let ss_plugin, ss_plugin_opts;
if (url.search && url.searchParams.plugin) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'shadowsocks',
address: url.hostname,
port: url.port,
shadowsocks_encrypt_method: ss_userinfo[0],
password: ss_userinfo[1],
shadowsocks_plugin: ss_plugin,
shadowsocks_plugin_opts: ss_plugin_opts
};
break;
case 'trojan':
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'trojan',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
transport: (params.type !== 'tcp') ? params.type : null,
tls: '1',
tls_sni: params.sni
};
switch(params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
break;
case 'tuic':
/* https://github.com/daeuniverse/dae/discussions/182 */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
if (!sing_features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'tuic',
address: url.hostname,
port: url.port,
uuid: url.username,
password: url.password ? urldecode(url.password) : null,
tuic_congestion_control: params.congestion_control,
tuic_udp_relay_mode: params.udp_relay_mode,
tls: '1',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
};
break;
case 'vless':
/* https://github.com/XTLS/Xray-core/discussions/716 */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !sing_features.with_quic)) {
log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!sing_features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'vless',
address: url.hostname,
port: url.port,
uuid: url.username,
transport: (params.type !== 'tcp') ? params.type : null,
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
tls_reality: (params.security === 'reality') ? '1' : '0',
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: sing_features.with_utls ? params.fp : null,
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
};
switch(params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'http':
case 'tcp':
if (params.type === 'http' || params.headerType === 'http') {
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
config.http_path = params.path ? urldecode(params.path) : null;
}
break;
case 'httpupgrade':
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
config.http_path = params.path ? urldecode(params.path) : null;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
break;
case 'vmess':
/* "Lovely" shadowrocket format */
if (match(uri, /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
try {
uri = json(decodeBase64Str(uri[1])) || {};
} catch(e) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
if (uri.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (uri.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
return null;
} else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || uri.path || !sing_features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
if (!sing_features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (uri.aid && int(uri.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
* return null;
* }
*/
config = {
label: uri.ps ? urldecode(uri.ps) : null,
type: 'vmess',
address: uri.add,
port: uri.port,
uuid: uri.id,
vmess_alterid: uri.aid,
vmess_encrypt: uri.scy || 'auto',
vmess_global_padding: '1',
transport: (uri.net !== 'tcp') ? uri.net : null,
tls: (uri.tls === 'tls') ? '1' : '0',
tls_sni: uri.sni || uri.host,
tls_alpn: uri.alpn ? split(uri.alpn, ',') : null,
tls_utls: sing_features.with_utls ? uri.fp : null
};
switch (uri.net) {
case 'grpc':
config.grpc_servicename = uri.path;
break;
case 'h2':
case 'tcp':
if (uri.net === 'h2' || uri.type === 'http') {
config.transport = 'http';
config.http_host = uri.host ? split(uri.host, ',') : null;
config.http_path = uri.path;
}
break;
case 'httpupgrade':
config.httpupgrade_host = uri.host;
config.http_path = uri.path;
break;
case 'ws':
config.ws_host = uri.host;
config.ws_path = uri.path;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
break;
}
}
if (!isEmpty(config)) {
if (config.address)
config.address = replace(config.address, /\[|\]/g, '');
if (!validation('host', config.address) || !validation('port', config.port)) {
log(sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL'));
return null;
} else if (!config.label)
config.label = (validation('ip6addr', config.address) ?
`[${config.address}]` : config.address) + ':' + config.port;
}
return config;
}
function main() {
for (let url in subscription_urls) {
url = replace(url, /#.*$/, '');
const groupHash = md5(url);
node_cache[groupHash] = {};
const fetch_result = wGETResult(url, user_agent) || {},
res = fetch_result.stdout;
if (isEmpty(res)) {
const reason = sanitizeCommandError(fetch_result.stderr);
log(sprintf('Failed to fetch resources from %s.', url));
if (!isEmpty(reason))
log(sprintf('Fetch stderr: %s', reason));
reportSubscriptionDiagnostic('error',
sprintf('订阅地址拉取失败:%s', url),
reason || '请检查路由器到订阅源的连通性、DNS 解析和 TLS 访问是否正常');
continue;
}
let nodes, parsed_as_surge = false;
try {
nodes = json(res).servers || json(res);
/* Shadowsocks SIP008 format */
if (nodes[0].server && nodes[0].method)
map(nodes, (_, i) => nodes[i].nodetype = 'sip008');
} catch(e) {
/* 先识别 Surge/Clash 托管配置格式(YAML 风格),再回退 Base64。 */
if (match(res, /(^|\n)[ \t]*proxies\s*:\s*(#[^\n]*)?(\n|$)/)) {
nodes = parse_surge_subscription({
isEmpty,
validation,
sing_features,
log,
apply_tls_cert_pin_policy
}, res);
parsed_as_surge = true;
} else {
nodes = decodeBase64Str(res);
nodes = nodes ? split(trim(replace(nodes, / /g, '_')), '\n') : [];
}
}
let count = 0;
for (let node in nodes) {
let config;
if (!isEmpty(node)) {
/* 信任边界来自控制流,而不是节点内容:只有 parse_surge_subscription()
* 分支产出的节点才会被当作已解析 config。任意 JSON 中伪造的
* 'nodetype'/'type' 不会绕过 parse_uri(),因为 JSON 路径下
* parsed_as_surge=false,仍然走原有校验和白名单映射。 */
if (parsed_as_surge && type(node) == 'object')
config = node;
else
config = parse_uri(node);
}
if (isEmpty(config))
continue;
if (!isEmpty(config.__skip_reason)) {
log(sprintf('Skipping node %s: %s', config.label || 'NULL', config.__skip_reason));
continue;
}
const label = config.label;
config.label = null;
const confHash = md5(sprintf('%J', config)),
nameHash = md5(label);
config.label = label;
if (filter_check(config.label))
log(sprintf('Skipping blacklist node: %s.', config.label));
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
log(sprintf('Skipping duplicate node: %s.', config.label));
else {
if (config.tls === '1' && allow_insecure === '1')
config.tls_insecure = '1';
if (config.type in ['vless', 'vmess'])
config.packet_encoding = packet_encoding;
config.grouphash = groupHash;
push(node_result, []);
push(node_result[length(node_result)-1], config);
node_cache[groupHash][confHash] = config;
node_cache[groupHash][nameHash] = config;
count++;
}
}
if (count == 0)
log(sprintf('No valid node found in %s.', url));
else
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), url));
}
if (isEmpty(node_result)) {
let fetch_errors = filter(subscription_diagnostics, (item) => item?.type === 'error' && item?.source === 'subscription');
log('Failed to update subscriptions: no valid node found.');
if (!length(fetch_errors))
reportSubscriptionDiagnostic('error',
'订阅更新失败:没有找到可用节点。',
'请检查订阅地址、过滤规则,以及是否有节点因当前 sing-box 不支持证书指纹而被跳过');
return false;
}
let added = 0, updated = 0, removed = 0,
stale_nodes = [],
stale_labels = {};
uci.foreach(uciconfig, ucinode, (cfg) => {
/* Nodes created by the user */
if (!cfg.grouphash)
return null;
/* Empty object - failed to fetch nodes */
if (length(node_cache[cfg.grouphash]) === 0)
return null;
if (!node_cache[cfg.grouphash] || !node_cache[cfg.grouphash][cfg['.name']]) {
push(stale_nodes, cfg['.name']);
stale_labels[cfg['.name']] = cfg.label || cfg['.name'];
return null;
} else {
const node = node_cache[cfg.grouphash][cfg['.name']];
let node_changed = false;
for (let v in node) {
if (isEmpty(node[v]))
continue;
if (!(v in cfg) || !uci_value_equal(cfg[v], node[v]))
node_changed = true;
uci.set(uciconfig, cfg['.name'], v, node[v]);
}
map(keys(cfg), (v) => {
if (substr(v, 0, 1) == '.')
return null;
if (!(v in node) || isEmpty(node[v])) {
uci.delete(uciconfig, cfg['.name'], v);
node_changed = true;
}
});
if (node_changed)
updated++;
node.isExisting = true;
}
});
if (length(stale_nodes)) {
const cleanup = remove_subscription_nodes(uci, uciconfig, stale_nodes);
removed += cleanup.removed;
for (let node_id in stale_nodes)
log(sprintf('Removing node: %s.', stale_labels[node_id] || node_id));
if (cleanup.changed) {
if (uci.get(uciconfig, ucimain, 'main_node') !== main_node)
log(sprintf('Deleted subscription node affected main node, falling back to %s.',
uci.get(uciconfig, ucimain, 'main_node') || 'nil'));
if (uci.get(uciconfig, ucimain, 'main_udp_node') !== main_udp_node)
log(sprintf('Deleted subscription node affected main UDP node, falling back to %s.',
uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'));
for (let change in cleanup.changes)
logCleanupChange(change);
}
}
for (let nodes in node_result)
map(nodes, (node) => {
if (node.isExisting)
return null;
const nameHash = md5(node.label);
uci.set(uciconfig, nameHash, 'node');
map(keys(node), (v) => uci.set(uciconfig, nameHash, v, node[v]));
added++;
log(sprintf('Adding node: %s.', node.label));
});
uci.commit(uciconfig);
let need_restart = (via_proxy !== '1') || added > 0 || updated > 0 || removed > 0;
if (!isEmpty(main_node)) {
const has_server = !!uci.get_first(uciconfig, ucinode);
if (has_server) {
let main_urltest_nodes;
if (main_node === 'urltest') {
main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
setOrDeleteList(ucimain, 'main_urltest_nodes', main_urltest_nodes);
uci.commit(uciconfig);
}
if ((main_node === 'urltest') ? !length(main_urltest_nodes) : !uci.get(uciconfig, main_node)) {
uci.set(uciconfig, ucimain, 'main_node', 'nil');
uci.commit(uciconfig);
need_restart = true;
log('Main node is gone, disabling main node.');
}
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
let main_udp_urltest_nodes;
if (main_udp_node === 'urltest') {
main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [], (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
setOrDeleteList(ucimain, 'main_udp_urltest_nodes', main_udp_urltest_nodes);
uci.commit(uciconfig);
}
if ((main_udp_node === 'urltest') ? !length(main_udp_urltest_nodes) : !uci.get(uciconfig, main_udp_node)) {
uci.set(uciconfig, ucimain, 'main_udp_node', 'same');
uci.commit(uciconfig);
need_restart = true;
log('Main UDP node is gone, falling back to the main node.');
}
}
} else {
uci.set(uciconfig, ucimain, 'main_node', 'nil');
uci.set(uciconfig, ucimain, 'main_udp_node', 'nil');
uci.commit(uciconfig);
need_restart = true;
log('No available node, disable tproxy.');
}
}
if (need_restart) {
log('Restarting service...');
init_action('homeproxy', 'stop');
init_action('homeproxy', 'start');
}
log(sprintf('%s nodes added, %s updated, %s removed.', added, updated, removed));
log('Successfully updated subscriptions.');
return true;
}
if (!isEmpty(subscription_urls)) {
writeSubscriptionUpdateStatus(true, false, null, null);
try {
const ok = call(main);
writeSubscriptionUpdateStatus(false, true, ok === false ? false : true,
(ok === false) ? (subscription_diagnostics[0]?.message || '订阅更新失败。') : null);
} catch(e) {
const status_error = sprintf('订阅更新异常:%s: %s', e.type || 'error', e.message || e);
log('[FATAL ERROR] An error occurred during updating subscriptions:');
log(sprintf('%s: %s', e.type, e.message));
log(e.stacktrace[0].context);
reportSubscriptionDiagnostic('error',
status_error,
'请查看 HomeProxy 日志中的 [SUBSCRIBE] 记录并修复订阅配置');
log('Restarting service...');
init_action('homeproxy', 'stop');
init_action('homeproxy', 'start');
writeSubscriptionUpdateStatus(false, true, false, status_error);
}
} else {
subscription_diagnostics = [];
writeSubscriptionUpdateStatus(false, true, false, '未配置订阅地址。');
}
writeSubscriptionDiagnostics();
-373
View File
@@ -1,373 +0,0 @@
#!/bin/sh /etc/rc.common
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
USE_PROCD=1
START=99
STOP=10
CONF="homeproxy"
PROG="/usr/bin/sing-box"
HP_DIR="/etc/homeproxy"
RUN_DIR="/var/run/homeproxy"
LOG_PATH="$RUN_DIR/homeproxy.log"
# we don't know which is the default server, just take the first one
DNSMASQ_UCI_CONFIG="$(uci -q show "dhcp.@dnsmasq[0]" | awk 'NR==1 {split($0, conf, /[.=]/); print conf[2]}')"
if [ -f "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG" ]; then
DNSMASQ_DIR="$(awk -F '=' '/^conf-dir=/ {print $2}' "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG")/dnsmasq-homeproxy.d"
else
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
fi
log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
}
delete_ip_rules() {
local mark="$1"
local table="$2"
while ip rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
:
done
}
delete_ip6_rules() {
local mark="$1"
local table="$2"
while ip -6 rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
:
done
}
start_service() {
config_load "$CONF"
local routing_mode proxy_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
local outbound_node default_outbound
config_get outbound_node "config" "main_node" "nil"
config_get default_outbound "routing" "default_outbound" "nil"
[ "$outbound_node" != "nil" ] || outbound_node="$default_outbound"
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
if [ "$outbound_node" = "nil" ] && [ "$server_enabled" = "0" ]; then
return 1
fi
mkdir -p "$RUN_DIR"
if [ "$outbound_node" != "nil" ]; then
# Generate/Validate client config
ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"
if [ ! -e "$RUN_DIR/sing-box-c.json" ]; then
log "Error: failed to generate client configuration."
return 1
elif ! "$PROG" check --config "$RUN_DIR/sing-box-c.json" 2>>"$LOG_PATH"; then
log "Error: wrong client configuration detected."
return 1
fi
# Auto update
local auto_update auto_update_time
config_get_bool auto_update "subscription" "auto_update" "0"
if [ "$auto_update" = "1" ]; then
config_get auto_update_time "subscription" "auto_update_time" "2"
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
echo -e "0 $auto_update_time * * * $HP_DIR/scripts/update_crond.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
/etc/init.d/cron restart
fi
# DNSMasq rules
local ipv6_support dns_port
config_get_bool ipv6_support "config" "ipv6_support" "0"
config_get dns_port "infra" "dns_port" "5333"
mkdir -p "$DNSMASQ_DIR"
echo -e "conf-dir=$DNSMASQ_DIR" > "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf"
case "$routing_mode" in
"bypass_mainland_china"|"custom"|"global")
cat <<-EOF >> "$DNSMASQ_DIR/redirect-dns.conf"
no-poll
no-resolv
server=127.0.0.1#$dns_port
EOF
;;
"gfwlist")
local gfw_nftset_v6
[ "$ipv6_support" -eq "0" ] || gfw_nftset_v6=",6#inet#fw4#homeproxy_gfw_list_v6"
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_gfw_list_v4$gfw_nftset_v6/g" \
"$HP_DIR/resources/gfw_list.txt" > "$DNSMASQ_DIR/gfw_list.conf"
;;
"proxy_mainland_china")
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
"$HP_DIR/resources/china_list.txt" > "$DNSMASQ_DIR/china_list.conf"
;;
esac
if [ "$routing_mode" != "custom" ] && [ -s "$HP_DIR/resources/proxy_list.txt" ]; then
local wan_nftset_v6
[ "$ipv6_support" -eq "0" ] || wan_nftset_v6=",6#inet#fw4#homeproxy_wan_proxy_addr_v6"
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
"$HP_DIR/resources/proxy_list.txt" > "$DNSMASQ_DIR/proxy_list.conf"
fi
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
# Setup routing table
local table_mark
config_get table_mark "infra" "table_mark" "100"
case "$proxy_mode" in
"redirect_tproxy")
local outbound_udp_node main_node_for_routing default_outbound_for_routing use_default_outbound_routing
config_get outbound_udp_node "config" "main_udp_node" "nil"
config_get main_node_for_routing "config" "main_node" "nil"
config_get default_outbound_for_routing "routing" "default_outbound" "nil"
[ "$routing_mode" != "custom" ] && [ "$main_node_for_routing" = "nil" ] && [ "$default_outbound_for_routing" != "nil" ] \
&& use_default_outbound_routing="1" || use_default_outbound_routing="0"
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ] || [ "$use_default_outbound_routing" = "1" ]; then
local tproxy_mark
config_get tproxy_mark "infra" "tproxy_mark" "101"
delete_ip_rules "$tproxy_mark" "$table_mark"
ip rule add fwmark "$tproxy_mark" table "$table_mark"
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
if [ "$ipv6_support" -eq "1" ]; then
delete_ip6_rules "$tproxy_mark" "$table_mark"
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark"
ip -6 route replace local ::/0 dev lo table "$table_mark"
fi
fi
;;
"redirect_tun"|"tun")
local tun_name tun_mark
config_get tun_name "infra" "tun_name" "singtun0"
config_get tun_mark "infra" "tun_mark" "102"
ip link show "$tun_name" >"/dev/null" 2>&1 || ip tuntap add mode tun user root name "$tun_name"
sleep 1s
ip link set "$tun_name" up
ip route replace default dev "$tun_name" table "$table_mark"
delete_ip_rules "$tun_mark" "$table_mark"
ip rule add fwmark "$tun_mark" lookup "$table_mark"
ip -6 route replace default dev "$tun_name" table "$table_mark"
delete_ip6_rules "$tun_mark" "$table_mark"
ip -6 rule add fwmark "$tun_mark" lookup "$table_mark"
;;
esac
# sing-box (client)
procd_open_instance "sing-box-c"
procd_set_param command "$PROG"
procd_append_param command run --config "$RUN_DIR/sing-box-c.json"
# QUIC-GO GSO is broken on kernel 6.6 currently
uname -r | grep -Eq "^6\.6" && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
if [ -x "/sbin/ujail" ] && [ "$routing_mode" != "custom" ] && ! grep -Eq '"type": "(wireguard|tun)"' "$RUN_DIR/sing-box-c.json"; then
procd_add_jail "sing-box-c" log procfs
procd_add_jail_mount "$RUN_DIR/sing-box-c.json"
procd_add_jail_mount_rw "$RUN_DIR/sing-box-c.log"
[ "$routing_mode" != "bypass_mainland_china" ] || procd_add_jail_mount_rw "$RUN_DIR/cache.db"
procd_add_jail_mount "$HP_DIR/certs/"
procd_add_jail_mount "/etc/ssl/"
procd_add_jail_mount "/etc/localtime"
procd_add_jail_mount "/etc/TZ"
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
fi
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
# Clash API display proxy for dashboards.
local clash_api_enabled
config_get_bool clash_api_enabled "config" "clash_api_enabled" "0"
if [ "$clash_api_enabled" = "1" ]; then
procd_open_instance "clash-api-proxy"
procd_set_param command "/usr/bin/ucode" "$HP_DIR/scripts/clash_api_proxy.uc"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
fi
fi
if [ "$server_enabled" = "1" ]; then
# Generate/Validate server config
ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"
if [ ! -e "$RUN_DIR/sing-box-s.json" ]; then
log "Error: failed to generate server configuration."
return 1
elif ! "$PROG" check --config "$RUN_DIR/sing-box-s.json" 2>>"$LOG_PATH"; then
log "Error: wrong server configuration detected."
return 1
fi
# sing-box (server)
procd_open_instance "sing-box-s"
procd_set_param command "$PROG"
procd_append_param command run --config "$RUN_DIR/sing-box-s.json"
# QUIC-GO GSO is broken on kernel 6.6 currently
uname -r | grep -Eq "^6\.6" && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
if [ -x "/sbin/ujail" ]; then
procd_add_jail "sing-box-s" log procfs
procd_add_jail_mount "$RUN_DIR/sing-box-s.json"
procd_add_jail_mount_rw "$RUN_DIR/sing-box-s.log"
procd_add_jail_mount_rw "$HP_DIR/certs/"
procd_add_jail_mount "/etc/acme/"
procd_add_jail_mount "/etc/ssl/"
procd_add_jail_mount "/etc/localtime"
procd_add_jail_mount "/etc/TZ"
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
fi
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
fi
# log-cleaner
procd_open_instance "log-cleaner"
procd_set_param command "$HP_DIR/scripts/clean_log.sh"
procd_set_param respawn
procd_close_instance
case "$routing_mode" in
"bypass_mainland_china")
# Prepare cache db
[ -e "$RUN_DIR/cache.db" ] || touch "$RUN_DIR/cache.db"
;;
"custom")
# Prepare ruleset directory
[ -d "$HP_DIR/ruleset" ] || mkdir -p "$HP_DIR/ruleset"
;;
esac
[ "$outbound_node" = "nil" ] || echo > "$RUN_DIR/sing-box-c.log"
if [ "$server_enabled" = "1" ]; then
echo > "$RUN_DIR/sing-box-s.log"
mkdir -p "$HP_DIR/certs"
fi
# Update permissions for ujail
chown -R sing-box:sing-box "$RUN_DIR"
# Setup firewall
ucode "$HP_DIR/scripts/firewall_pre.uc"
[ "$outbound_node" = "nil" ] || utpl -S "$HP_DIR/scripts/firewall_post.ut" > "$RUN_DIR/fw4_post.nft"
fw4 reload >"/dev/null" 2>&1
log "sing-box $(sing-box version -n) started."
}
stop_service() {
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
/etc/init.d/cron restart >"/dev/null" 2>&1
# Setup firewall
# Load config
config_load "$CONF"
local table_mark tproxy_mark tun_mark tun_name
config_get table_mark "infra" "table_mark" "100"
config_get tproxy_mark "infra" "tproxy_mark" "101"
config_get tun_mark "infra" "tun_mark" "102"
config_get tun_name "infra" "tun_name" "singtun0"
# Tproxy
delete_ip_rules "$tproxy_mark" "$table_mark"
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
delete_ip6_rules "$tproxy_mark" "$table_mark"
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
# TUN
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
delete_ip_rules "$tun_mark" "$table_mark"
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
delete_ip6_rules "$tun_mark" "$table_mark"
# Nftables rules
for i in "homeproxy_dstnat_redir" "homeproxy_output_redir" \
"homeproxy_redirect" "homeproxy_redirect_proxy" \
"homeproxy_redirect_proxy_port" "homeproxy_redirect_lanac" \
"homeproxy_mangle_prerouting" "homeproxy_mangle_output" \
"homeproxy_mangle_tproxy" "homeproxy_mangle_tproxy_port" \
"homeproxy_mangle_tproxy_lanac" "homeproxy_mangle_mark" \
"homeproxy_mangle_tun" "homeproxy_mangle_tun_mark"; do
nft flush chain inet fw4 "$i"
nft delete chain inet fw4 "$i"
done 2>"/dev/null"
for i in "homeproxy_local_addr_v4" "homeproxy_local_addr_v6" \
"homeproxy_gfw_list_v4" "homeproxy_gfw_list_v6" \
"homeproxy_mainland_addr_v4" "homeproxy_mainland_addr_v6" \
"homeproxy_wan_proxy_addr_v4" "homeproxy_wan_proxy_addr_v6" \
"homeproxy_wan_direct_addr_v4" "homeproxy_wan_direct_addr_v6" \
"homeproxy_routing_port"; do
nft flush set inet fw4 "$i"
nft delete set inet fw4 "$i"
done 2>"/dev/null"
echo 2>"/dev/null" > "$RUN_DIR/fw4_forward.nft"
echo 2>"/dev/null" > "$RUN_DIR/fw4_input.nft"
echo 2>"/dev/null" > "$RUN_DIR/fw4_post.nft"
fw4 reload >"/dev/null" 2>&1
# Remove DNS hijack
rm -rf "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" "$DNSMASQ_DIR"
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log"
log "Service stopped."
}
service_stopped() {
# Load config
config_load "$CONF"
local tun_name
config_get tun_name "infra" "tun_name" "singtun0"
# TUN
ip link set "$tun_name" down 2>"/dev/null"
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
}
reload_service() {
log "Reloading service..."
stop
start
}
service_triggers() {
procd_add_reload_trigger "$CONF"
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/$CONF reload
}
@@ -1,31 +0,0 @@
#!/bin/sh
[ -f "/www/luci-static/resources/icons/loading.gif" ] && \
sed -i "s,/loading.svg,/loading.gif,g" "/www/luci-static/resources/view/homeproxy/status.js"
uci -q batch <<-EOF >"/dev/null"
delete firewall.homeproxy_pre
delete firewall.homeproxy_forward
set firewall.homeproxy_forward=include
set firewall.homeproxy_forward.type=nftables
set firewall.homeproxy_forward.path="/var/run/homeproxy/fw4_forward.nft"
set firewall.homeproxy_forward.position="chain-pre"
set firewall.homeproxy_forward.chain="forward"
delete firewall.homeproxy_input
set firewall.homeproxy_input=include
set firewall.homeproxy_input.type=nftables
set firewall.homeproxy_input.path="/var/run/homeproxy/fw4_input.nft"
set firewall.homeproxy_input.position="chain-pre"
set firewall.homeproxy_input.chain="input"
delete firewall.homeproxy_post
set firewall.homeproxy_post=include
set firewall.homeproxy_post.type=nftables
set firewall.homeproxy_post.path="/var/run/homeproxy/fw4_post.nft"
set firewall.homeproxy_post.position="table-post"
commit firewall
EOF
exit 0
@@ -1,5 +0,0 @@
#!/bin/sh
ucode "/etc/homeproxy/scripts/migrate_config.uc"
exit 0
@@ -1,53 +0,0 @@
{
"admin/services/homeproxy": {
"title": "HomeProxy",
"order": 10,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-homeproxy" ],
"uci": { "homeproxy": true }
}
},
"admin/services/homeproxy/client": {
"title": "Client Settings",
"order": 10,
"action": {
"type": "view",
"path": "homeproxy/client"
}
},
"admin/services/homeproxy/node": {
"title": "Node Settings",
"order": 15,
"action": {
"type": "view",
"path": "homeproxy/node"
}
},
"admin/services/homeproxy/server": {
"title": "Server Settings",
"order": 20,
"action": {
"type": "view",
"path": "homeproxy/server"
}
},
"admin/services/homeproxy/backup": {
"title": "Backup / Restore",
"order": 25,
"action": {
"type": "view",
"path": "homeproxy/backup"
}
},
"admin/services/homeproxy/status": {
"title": "Service Status",
"order": 30,
"action": {
"type": "view",
"path": "homeproxy/status"
}
}
}
@@ -1,62 +0,0 @@
{
"luci-app-homeproxy": {
"description": "Grant access to homeproxy configuration",
"read": {
"file": {
"/var/run/homeproxy/homeproxy.log": [ "read" ],
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
},
"ubus": {
"service": [ "list" ],
"luci.homeproxy": [
"acllist_read",
"connection_check",
"resources_get_version",
"singbox_get_features"
],
"luci.homeproxy_node_tools": [
"config_diagnostics",
"node_references",
"preview_node_filter",
"update_subscriptions_status",
"validate_regex"
]
},
"uci": [ "homeproxy" ]
},
"write": {
"file": {
"/tmp/homeproxy_certificate.tmp": [ "write" ],
"/tmp/homeproxy-backup-*.tar.gz": [ "read" ],
"/tmp/homeproxy-restore-*.tar.gz": [ "write" ]
},
"ubus": {
"luci.homeproxy": [
"acllist_read",
"acllist_write",
"certificate_write",
"log_clean",
"resources_update",
"singbox_generator"
],
"luci.homeproxy_backup": [
"backup_cleanup",
"backup_create",
"backup_get_upload_path",
"backup_restore",
"backup_validate"
],
"luci.homeproxy_node_tools": [
"remove_subscription_nodes",
"update_subscriptions"
],
"luci.homeproxy_tcping": [
"node_tcping",
"nodes_tcping"
]
},
"uci": [ "homeproxy" ]
}
}
}
@@ -1,259 +0,0 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023-2024 ImmortalWrt.org
*/
'use strict';
import { access, error, lstat, popen, readfile, writefile } from 'fs';
/* Kanged from ucode/luci */
function shellquote(s) {
return `'${replace(s, "'", "'\\''")}'`;
}
function hasKernelModule(kmod) {
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellquote(kmod))) === 0);
}
const HP_DIR = '/etc/homeproxy';
const RUN_DIR = '/var/run/homeproxy';
const methods = {
acllist_read: {
args: { type: 'type' },
call: function(req) {
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
return { content: null, error: 'illegal type' };
const filecontent = readfile(`${HP_DIR}/resources/${req.args?.type}.txt`);
return { content: filecontent };
}
},
acllist_write: {
args: { type: 'type', content: 'content' },
call: function(req) {
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
return { result: false, error: 'illegal type' };
const file = `${HP_DIR}/resources/${req.args?.type}.txt`;
let content = req.args?.content;
/* Sanitize content */
if (content) {
content = trim(content);
content = replace(content, /\r\n?/g, '\n');
if (!match(content, /\n$/))
content += '\n';
}
system(`mkdir -p ${HP_DIR}/resources`);
writefile(file, content);
return { result: true };
}
},
certificate_write: {
args: { filename: 'filename' },
call: function(req) {
const writeCertificate = (filename, priv) => {
const tmpcert = '/tmp/homeproxy_certificate.tmp';
const filestat = lstat(tmpcert);
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'empty certificate file' };
}
let filecontent = readfile(tmpcert);
if (is_binary(filecontent)) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'illegal file type: binary' };
}
/* Kanged from luci-proto-openconnect */
const beg = priv ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
end = priv ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/,
lines = split(trim(filecontent), /[\r\n]/);
let start = false, i;
for (i = 0; i < length(lines); i++) {
if (match(lines[i], beg))
start = true;
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
break;
}
if (!start || i < length(lines) - 1 || !match(lines[i], end)) {
system(`rm -f ${tmpcert}`);
return { result: false, error: 'this does not look like a correct PEM file' };
}
/* Sanitize certificate */
filecontent = trim(filecontent);
filecontent = replace(filecontent, /\r\n?/g, '\n');
if (!match(filecontent, /\n$/))
filecontent += '\n';
system(`mkdir -p ${HP_DIR}/certs`);
writefile(`${HP_DIR}/certs/${filename}.pem`, filecontent);
system(`rm -f ${tmpcert}`);
return { result: true };
};
const filename = req.args?.filename;
switch (filename) {
case 'client_ca':
case 'server_publickey':
return writeCertificate(filename, false);
break;
case 'server_privatekey':
return writeCertificate(filename, true);
break;
default:
return { result: false, error: 'illegal cerificate filename' };
break;
}
}
},
connection_check: {
args: { site: 'site' },
call: function(req) {
let url;
switch(req.args?.site) {
case 'baidu':
url = 'https://www.baidu.com';
break;
case 'google':
url = 'https://www.google.com';
break;
default:
return { result: false, error: 'illegal site' };
break;
}
return { result: (system(`/usr/bin/wget --spider -qT3 ${url} 2>"/dev/null"`, 3100) === 0) };
}
},
log_clean: {
args: { type: 'type' },
call: function(req) {
if (!(req.args?.type in ['homeproxy', 'sing-box-c', 'sing-box-s']))
return { result: false, error: 'illegal type' };
const filestat = lstat(`${RUN_DIR}/${req.args?.type}.log`);
if (filestat)
writefile(`${RUN_DIR}/${req.args?.type}.log`, '');
return { result: true };
}
},
singbox_generator: {
args: { type: 'type', params: 'params' },
call: function(req) {
if (!(req.args?.type in ['ech-keypair', 'uuid', 'reality-keypair', 'vapid-keypair', 'wg-keypair']))
return { result: false, error: 'illegal type' };
const type = req.args?.type;
let result = {};
const fd = popen('/usr/bin/sing-box generate ' + type + ' ' + shellquote(req.args?.params || ''));
if (fd) {
let ech_cfg_set = false;
let ech_key_set = false;
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (type === 'uuid')
result.uuid = trim(line);
else if (type in ['reality-keypair', 'vapid-keypair', 'wg-keypair']) {
let priv = match(trim(line), /PrivateKey: (.*)/);
if (priv)
result.private_key = priv[1];
let pub = match(trim(line), /PublicKey: (.*)/);
if (pub)
result.public_key = pub[1];
} else if (type in ['ech-keypair']) {
if (trim(line) === '-----BEGIN ECH CONFIGS-----')
ech_cfg_set = true;
else if (trim(line) === '-----BEGIN ECH KEYS-----')
ech_key_set = true;
if (ech_cfg_set)
result.ech_cfg = result.ech_cfg ? result.ech_cfg + '\n' + trim(line) : trim(line) ;
if (ech_key_set)
result.ech_key = result.ech_key ? result.ech_key + '\n' + trim(line) : trim(line) ;
if (trim(line) === '-----END ECH CONFIGS-----')
ech_cfg_set = false;
else if (trim(line) === '-----END ECH KEYS-----')
ech_key_set = false;
}
}
fd.close();
}
return { result };
}
},
singbox_get_features: {
call: function() {
let features = {};
const fd = popen('/usr/bin/sing-box version');
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (match(trim(line), /^sing-box version (.*)/))
features.version = match(trim(line), /^sing-box version (.*)/)[1];
let tags = match(trim(line), /^Tags: (.*)/);
if (tags)
for (let i in split(tags[1], ','))
features[i] = true;
}
fd.close();
}
features.hp_has_ip_full = access('/usr/libexec/ip-full');
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
return features;
}
},
resources_get_version: {
args: { type: 'type' },
call: function(req) {
const allowed_types = [ 'china_ip4', 'china_ip6', 'china_list', 'gfw_list' ];
if (index(allowed_types, req.args?.type) === -1)
return { version: null, error: 'invalid resource type' };
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`) || '');
return { version, error: error() };
}
},
resources_update: {
args: { type: 'type' },
call: function(req) {
if (req.args?.type) {
const type = shellquote(req.args?.type);
const exit_code = system(`${HP_DIR}/scripts/update_resources.sh ${type}`);
return { status: exit_code };
} else
return { status: 255, error: 'illegal type' };
}
}
};
return { 'luci.homeproxy': methods };
File renamed without changes.
File renamed without changes.
+7 -2
View File
@@ -1,15 +1,20 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2024 ImmortalWrt.org
# Copyright (C) 2022-2023 ImmortalWrt.org
include $(TOPDIR)/rules.mk
LUCI_TITLE:=The modern ImmortalWrt proxy platform for ARM64/AMD64
LUCI_PKGARCH:=all
LUCI_DEPENDS:= \
+sing-box \
+firewall4 \
+kmod-nft-tproxy \
+ucode-mod-digest
+ucode-mod-digest \
+ucode-mod-socket \
+ucode-mod-uloop
PKG_NAME:=luci-app-homeproxy
define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy
File renamed without changes.
File renamed without changes.
@@ -222,6 +222,47 @@ return baseclass.extend({
return label ? title + ' » ' + label : addtitle;
},
toArray(value) {
if (Array.isArray(value))
return value;
else if (value == null || value === '')
return [];
return [ value ];
},
showConfigDiagnostics(diagnostics) {
let items = diagnostics?.items || [],
generatedAt = null,
noticeColor = '#1f232b',
noticeAccentColor = '#8b1e1e';
document.querySelectorAll('.homeproxy-config-diagnostics').forEach((node) => node.remove());
if (!items.length)
return;
if (diagnostics?.time) {
let date = new Date(Number(diagnostics.time) * 1000);
if (!isNaN(date.getTime()))
generatedAt = date.toLocaleString();
}
ui.addNotification(null, E('div', { 'style': 'color:%s;'.format(noticeColor) }, [
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeColor) }, _('HomeProxy found the following issues. Check and fix them manually:')),
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeAccentColor) },
generatedAt
? _('These diagnostics are from the last configuration generation or subscription update (%s). Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.').format(generatedAt)
: _('These diagnostics are from the last configuration generation or subscription update. Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.')),
E('ul', { 'style': 'color:%s;'.format(noticeColor) }, items.map((item) => E('li', { 'style': 'color:%s;'.format(noticeColor) }, [
E('strong', { 'style': 'color:%s;'.format(noticeColor) }, '[' + (item.type || 'warning') + '] '),
item.message || '',
item.suggestion ? E('div', { 'style': 'margin-top:.2em;color:%s;font-weight:600;'.format(noticeAccentColor) },
_('Suggestion:') + item.suggestion) : ''
])))
]), 'warning', 'homeproxy-config-diagnostics');
},
renderSectionAdd(section, extra_class) {
let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
nameEl = el.querySelector('.cbi-section-create-name');
@@ -304,7 +345,7 @@ return baseclass.extend({
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
if (ucioption === 'node' && value === 'urltest')
if (ucioption === 'node' && ['urltest', 'selector'].includes(value))
return true;
let duplicate = false;
@@ -5,6 +5,7 @@
*/
'use strict';
'require dom';
'require form';
'require network';
'require poll';
@@ -14,6 +15,10 @@
'require view';
'require homeproxy as hp';
'require homeproxy.dashboard as hpDashboard';
'require homeproxy.diagnostics as hpDiagnostics';
'require homeproxy.node-filter as hpNodeFilter';
'require homeproxy.routing-node as hpRoutingNode';
'require tools.firewall as fwtool';
'require tools.widgets as widgets';
@@ -77,7 +82,8 @@ return view.extend({
return Promise.all([
uci.load('homeproxy'),
hp.getBuiltinFeatures(),
network.getHostHints()
network.getHostHints(),
hpDiagnostics.load()
]);
},
@@ -87,6 +93,8 @@ return view.extend({
let features = data[1],
hosts = data[2]?.hosts;
hpDiagnostics.show(data[3]);
/* Cache all configured proxy nodes, they will be called multiple times */
let proxy_nodes = {};
uci.sections(data[0], 'node', (res) => {
@@ -98,6 +106,37 @@ return view.extend({
String.format('[%s]', nodeaddr) : nodeaddr) + ':' + nodeport));
});
let renderDashboardButton = function() {
return hpDashboard.renderButton(uci, data[0]);
};
let addSelectableOutbounds = function(option, section_id, include_routing_nodes) {
hpRoutingNode.addSelectableOutbounds(option, data[0], proxy_nodes, section_id, include_routing_nodes);
};
let routing_node_names = hpRoutingNode.buildRoutingNodeNames(data[0]);
let nodeFilterPreviewName = function(node_id) {
return hpRoutingNode.nodeDisplayName(node_id, proxy_nodes, routing_node_names);
};
let currentOptionValue = function(section, section_id, option, fallback) {
let opt = section.map.lookupOption(option, section_id)?.[0];
if (opt) {
let value = opt.formvalue(section_id);
if (value != null)
return value;
}
let value = section.formvalue(section_id, option);
return (value != null) ? value : fallback;
};
let pathCache = {};
let selectorHasPath = function(start, target, seen) {
return hpRoutingNode.selectorHasPath(data[0], start, target, pathCache, seen || {});
};
m = new form.Map('homeproxy', _('HomeProxy'),
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
@@ -111,7 +150,8 @@ return view.extend({
});
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
E('p', { id: 'service_status' }, _('Collecting data...'))
E('p', { id: 'service_status' }, _('Collecting data...')),
renderDashboardButton() || ''
]);
}
@@ -419,6 +459,7 @@ return view.extend({
so = ss.option(form.ListValue, 'node', _('Node'),
_('Outbound node'));
so.value('urltest', _('URLTest'));
so.value('selector', _('Selector'));
for (let i in proxy_nodes)
so.value(i, proxy_nodes[i]);
so.validate = L.bind(hp.validateUniqueValue, this, data[0], 'routing_node', 'node');
@@ -440,21 +481,21 @@ return view.extend({
return this.super('load', section_id);
}
so.depends({'node': 'urltest', '!reverse': true});
so.depends({'node': /^((?!(urltest|selector)$).)+$/});
so.modalonly = true;
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
_('The domain strategy for resolving the domain name in the address.'));
for (let i in hp.dns_strategy)
so.value(i, hp.dns_strategy[i]);
so.depends({'node': 'urltest', '!reverse': true});
so.depends({'node': /^((?!(urltest|selector)$).)+$/});
so.modalonly = true;
so = ss.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
_('The network interface to bind to.'));
so.multiple = false;
so.noaliases = true;
so.depends({'outbound': '', 'node': /^((?!urltest$).)+$/});
so.depends({'outbound': '', 'node': /^((?!(urltest|selector)$).)+$/});
so.modalonly = true;
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
@@ -472,25 +513,12 @@ return view.extend({
return this.super('load', section_id);
}
so.validate = function(section_id, value) {
if (section_id && value) {
let node = this.section.formvalue(section_id, 'node');
let conflict = false;
uci.sections(data[0], 'routing_node', (res) => {
if (res['.name'] !== section_id) {
if (res.outbound === section_id && res['.name'] == value)
conflict = true;
else if (res.node === 'urltest' && res.urltest_nodes?.includes(node) && res['.name'] == value)
conflict = true;
}
});
if (conflict)
return _('Recursive outbound detected!');
}
if (section_id && value && selectorHasPath(value, section_id, {}))
return _('Recursive outbound detected!');
return true;
}
so.depends({'node': 'urltest', '!reverse': true});
so.depends({'node': /^((?!(urltest|selector)$).)+$/});
so.editable = true;
so = ss.option(hp.CBIStaticList, 'urltest_nodes', _('URLTest nodes'),
@@ -499,14 +527,189 @@ return view.extend({
so.value(i, proxy_nodes[i]);
so.depends('node', 'urltest');
so.validate = function(section_id) {
let value = this.section.formvalue(section_id, 'urltest_nodes');
if (section_id && !value.length)
let value = hpNodeFilter.normalizeNodeList(currentOptionValue(this.section, section_id, 'urltest_nodes', []));
let node_filter = currentOptionValue(this.section, section_id, 'node_filter', '');
if (section_id && !value.length && !node_filter)
return _('Expecting: %s').format(_('non-empty value'));
return true;
}
so.modalonly = true;
so = ss.option(hp.CBIStaticList, 'selector_nodes', _('Selector nodes'),
_('List of manually selectable nodes.'));
so.load = function(section_id) {
delete this.keylist;
delete this.vallist;
addSelectableOutbounds(this, section_id, true);
return this.super('load', section_id);
}
so.depends('node', 'selector');
so.validate = function(section_id) {
let value = hpNodeFilter.normalizeNodeList(currentOptionValue(this.section, section_id, 'selector_nodes', []));
let node_filter = currentOptionValue(this.section, section_id, 'node_filter', '');
if (section_id && !value.length && !node_filter)
return _('Expecting: %s').format(_('non-empty value'));
for (let i in value)
if (selectorHasPath(value[i], section_id, {}))
return _('Recursive outbound detected!');
return true;
}
so.modalonly = true;
so = ss.option(form.Value, 'node_filter', _('Node regex'),
_('Use POSIX ERE syntax. Lookahead/lookbehind such as (?!) and (?<=) are unsupported.') +
'<br/>' +
_('Maximum regex length is 512 characters; preview and generation cap regex-expanded nodes to prevent oversized groups.') +
'<br/>' +
_('Effective nodes are recalculated automatically after saving and applying manual node changes, or after manual/scheduled subscription updates finish.'));
so.depends('node', 'urltest');
so.depends('node', 'selector');
so.forcewrite = true;
so.write = function(section_id, value) {
// LuCI 的字段校验是同步的,RPC 校验放在 write() 里让保存流程等待结果。
return hpNodeFilter.validate(value).then((res) => {
if (!res.result) {
let message = _('Expecting: %s').format(_('valid regular expression'));
if (res.error)
message += ': ' + res.error;
return Promise.reject(new TypeError(message));
}
return this.map.data.set(
this.uciconfig ?? this.section.uciconfig ?? this.map.config,
this.ucisection ?? section_id,
this.ucioption ?? this.option,
value);
});
}
so.modalonly = true;
so = ss.option(form.Value, 'node_filter_exclude', _('Node exclude regex'),
_('Exclude proxy nodes whose labels match this regex from the final effective node set, including manually selected nodes. Leave empty to exclude nothing. To exclude from all nodes, set the Node regex above to .*'));
so.depends('node', 'urltest');
so.depends('node', 'selector');
so.forcewrite = true;
so.write = function(section_id, value) {
// 同 node_filter:RPC 校验放在 write() 里让保存流程等待结果。
return hpNodeFilter.validate(value).then((res) => {
if (!res.result) {
let message = _('Expecting: %s').format(_('valid regular expression'));
if (res.error)
message += ': ' + res.error;
return Promise.reject(new TypeError(message));
}
return this.map.data.set(
this.uciconfig ?? this.section.uciconfig ?? this.map.config,
this.ucisection ?? section_id,
this.ucioption ?? this.option,
value);
});
};
so.modalonly = true;
so = ss.option(form.DummyValue, '_node_filter_preview', _('Effective nodes'));
so.depends('node', 'urltest');
so.depends('node', 'selector');
so.renderWidget = function(section_id) {
let container = E('div', { 'class': 'homeproxy-node-filter-preview' });
let request_id = 0,
timer = null;
let refresh = () => {
if (!document.body.contains(container))
return;
let current_id = ++request_id,
node = currentOptionValue(this.section, section_id, 'node', ''),
node_filter = currentOptionValue(this.section, section_id, 'node_filter', '') || '',
node_filter_exclude = currentOptionValue(this.section, section_id, 'node_filter_exclude', '') || '',
manual_nodes = [];
if (!String(node_filter).trim() && !String(node_filter_exclude).trim()) {
hpNodeFilter.setPreviewVisible(container, false);
dom.content(container, '');
return;
}
hpNodeFilter.setPreviewVisible(container, true);
dom.content(container, _('Loading...'));
if (node === 'urltest')
manual_nodes = hpNodeFilter.normalizeNodeList(currentOptionValue(this.section, section_id, 'urltest_nodes', []));
else if (node === 'selector')
manual_nodes = hpNodeFilter.normalizeNodeList(currentOptionValue(this.section, section_id, 'selector_nodes', []));
hpNodeFilter.preview(
manual_nodes,
node_filter,
node_filter_exclude,
node
).then((res) => {
if (current_id === request_id)
hpNodeFilter.renderPreview(container, res, nodeFilterPreviewName);
});
};
let schedule = () => {
if (!document.body.contains(container))
return;
if (timer)
window.clearTimeout(timer);
timer = window.setTimeout(refresh, 150);
};
window.setTimeout(() => {
let scope = container.closest('.cbi-modal') ||
container.closest('.modal') ||
container.closest('.cbi-section') ||
container.parentNode;
if (scope) {
scope.addEventListener('input', schedule, true);
scope.addEventListener('change', schedule, true);
scope.addEventListener('click', schedule, true);
}
schedule();
});
return container;
}
so.modalonly = true;
so = ss.option(form.ListValue, 'selector_default', _('Default selector node'));
so.load = function(section_id) {
delete this.keylist;
delete this.vallist;
this.value('', _('Default'));
addSelectableOutbounds(this, section_id, true);
return this.super('load', section_id);
}
so.validate = function(section_id, value) {
if (section_id && value && selectorHasPath(value, section_id, {}))
return _('Recursive outbound detected!');
return true;
}
so.depends('node', 'selector');
so.modalonly = true;
so = ss.option(form.Flag, 'selector_interrupt_exist_connections', _('Interrupt existing connections'),
_('Interrupt existing connections when the selected outbound has changed.'));
so.depends('node', 'selector');
so.modalonly = true;
so = ss.option(form.Value, 'urltest_url', _('Test URL'),
_('The URL to test.'));
so.placeholder = 'https://www.gstatic.com/generate_204';
@@ -682,10 +885,8 @@ return view.extend({
delete this.vallist;
this.value('direct-out', _('Direct'));
uci.sections(data[0], 'routing_node', (res) => {
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
this.value('block-out', _('Block'));
addSelectableOutbounds(this, section_id, true);
return this.super('load', section_id);
}
@@ -1031,6 +1232,7 @@ return view.extend({
delete this.vallist;
this.value('direct-out', _('Direct'));
this.value('block-out', _('Block'));
uci.sections(data[0], 'routing_node', (res) => {
if (res.enabled === '1')
this.value(res['.name'], res.label);
@@ -1380,6 +1582,7 @@ return view.extend({
this.value('', _('Default'));
this.value('direct-out', _('Direct'));
this.value('block-out', _('Block'));
uci.sections(data[0], 'routing_node', (res) => {
if (res.enabled === '1')
this.value(res['.name'], res.label);
@@ -6,19 +6,114 @@
'use strict';
'require form';
'require fs';
'require rpc';
'require uci';
'require ui';
'require view';
'require homeproxy as hp';
'require homeproxy.diagnostics as hpDiagnostics';
'require homeproxy.tcping as hpTcping';
'require tools.widgets as widgets';
const callNodeReferences = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'node_references',
params: [ 'node_id' ],
expect: { '': {} }
});
const callRemoveSubscriptionNodes = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'remove_subscription_nodes',
params: [ 'node_ids' ],
expect: { '': {} }
});
const callUpdateSubscriptions = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'update_subscriptions',
expect: { '': {} }
});
const callUpdateSubscriptionsStatus = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'update_subscriptions_status',
expect: { '': {} }
});
const SUBSCRIPTION_UPDATE_POLL_INTERVAL = 1000;
const SUBSCRIPTION_UPDATE_POLL_LIMIT = 180;
function subscriptionRpcErrorMessage(err, fallback) {
let message = err?.message || err;
if (message != null)
message = String(message);
if (!message || message === 'Unknown error.' || message === '未知错误。')
message = fallback;
let lower = (message || '').toLowerCase();
if (lower.includes('access denied') ||
lower.includes('permission denied') ||
lower.includes('ubus') ||
lower.includes('rpc') ||
lower.includes('session')) {
return _('HomeProxy was just upgraded or rpcd has restarted. The current page session may be expired. Refresh the page or sign in again before retrying.');
}
return message || fallback;
}
function allowInsecureConfirm(ev, _section_id, value) {
if (value === '1' && !confirm(_('Are you sure to allow insecure?')))
ev.target.firstElementChild.checked = null;
}
function showNodeReferenceNotice(section_id) {
return L.resolveDefault(callNodeReferences(section_id), { result: false, error: _('Unknown error.') }).then((res) => {
if (!res.result) {
ui.addNotification(null, E('p', res.error || _('Failed to check node references. Please try again later.')), 'warning');
return true;
}
let refs = res.references || [];
if (!refs.length)
return false;
let label = uci.get('homeproxy', section_id, 'label') || section_id;
ui.addNotification(null, E('div', [
E('p', _('Node %s is still referenced by these settings. Remove the references before deleting the node.').format(label)),
E('ul', refs.map((ref) => E('li', ref?.label || ref?.scope || '')))
]), 'warning');
return true;
}).catch((err) => {
ui.addNotification(null, E('p', _('Failed to check node references. Please try again later.')), 'warning');
return true;
});
}
function wait(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function pollSubscriptionUpdateStatus(attempt) {
return callUpdateSubscriptionsStatus().catch((err) => {
throw new Error(subscriptionRpcErrorMessage(err, _('Failed to read subscription update status.')));
}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Failed to read subscription update status.'));
if (!res.completed) {
if (attempt >= SUBSCRIPTION_UPDATE_POLL_LIMIT)
throw new Error(_('Timed out waiting for subscription update to finish.'));
return wait(SUBSCRIPTION_UPDATE_POLL_INTERVAL).then(() => pollSubscriptionUpdateStatus(attempt + 1));
}
return res;
});
}
function parseShareLink(uri, features) {
let config, url, params;
@@ -455,6 +550,27 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.depends({'type': 'direct', '!reverse': true});
o.rmempty = false;
o = s.option(form.DummyValue, '_tcping_delay', '延迟');
o.editable = true;
o.rmempty = true;
o.renderWidget = function(section_id) {
hpTcping.ensureStyle();
return E('span', {
id: hpTcping.nodeId(section_id),
class: 'homeproxy-node-tcping homeproxy-latency-pill',
role: 'button',
tabindex: '0',
title: '点击测速',
click: (ev) => hpTcping.runNode(section_id, ev),
keydown: (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
return hpTcping.runNode(section_id, ev);
}
}
}, '-');
}
o = s.option(form.Value, 'username', _('Username'));
o.depends('type', 'http');
o.depends('type', 'socks');
@@ -638,6 +754,69 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.depends('shadowsocks_plugin', 'obfs-local');
o.depends('shadowsocks_plugin', 'v2ray-plugin');
o.modalonly = true;
o = s.option(form.Flag, 'shadowtls_enabled', _('Enable ShadowTLS'));
o.depends('type', 'shadowsocks');
o.rmempty = false;
o.load = function(section_id) {
let enabled = uci.get(data[0], section_id, 'shadowtls_enabled');
if (enabled != null)
return enabled;
return uci.get(data[0], section_id, 'shadowtls_address') ? '1' : '0';
}
o.modalonly = true;
o = s.option(form.Value, 'shadowtls_address', _('ShadowTLS address'));
o.datatype = 'host';
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
o.validate = function(section_id, value) {
if (section_id) {
let type = this.section.formvalue(section_id, 'type');
let enabled = this.section.formvalue(section_id, 'shadowtls_enabled');
if (type === 'shadowsocks' && enabled === '1' && !value)
return _('Cannot be empty');
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'shadowtls_port', _('ShadowTLS port'));
o.datatype = 'port';
o.placeholder = '443';
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
o.validate = function(section_id, value) {
if (section_id) {
let type = this.section.formvalue(section_id, 'type');
let enabled = this.section.formvalue(section_id, 'shadowtls_enabled');
if (type === 'shadowsocks' && enabled === '1' && !value)
return _('Cannot be empty');
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'shadowtls_password', _('ShadowTLS password'));
o.password = true;
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
o.validate = function(section_id, value) {
if (section_id) {
let type = this.section.formvalue(section_id, 'type');
let enabled = this.section.formvalue(section_id, 'shadowtls_enabled');
if (type === 'shadowsocks' && enabled === '1' && !value)
return _('Cannot be empty');
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'shadowtls_sni', _('ShadowTLS masquerade SNI'));
o.datatype = 'hostname';
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
o.modalonly = true;
/* Shadowsocks config end */
/* ShadowTLS config */
@@ -647,6 +826,7 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) {
o.value('3', _('v3'));
o.default = '1';
o.depends('type', 'shadowtls');
o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'});
o.rmempty = false;
o.modalonly = true;
@@ -1189,7 +1369,8 @@ return view.extend({
load() {
return Promise.all([
uci.load('homeproxy'),
hp.getBuiltinFeatures()
hp.getBuiltinFeatures(),
hpDiagnostics.load()
]);
},
@@ -1198,6 +1379,7 @@ return view.extend({
let main_node = uci.get(data[0], 'config', 'main_node');
let routing_mode = uci.get(data[0], 'config', 'routing_mode');
let features = data[1];
hpDiagnostics.show(data[2]);
/* Cache subscription information, it will be called multiple times */
let subinfo = [];
@@ -1208,6 +1390,48 @@ return view.extend({
subinfo.push({ 'hash': urlhash, 'title': title });
}
let isSubscriptionGrouphash = function(grouphash) {
for (let info of subinfo)
if (info.hash === grouphash)
return true;
return false;
};
let collectNodeSections = function(grouphash) {
let sections = [];
uci.sections(data[0], 'node', (res) => {
if (grouphash ? res.grouphash === grouphash : !isSubscriptionGrouphash(res.grouphash))
sections.push(res['.name']);
});
return sections;
};
let addTcpingButton = function(tab, option, grouphash) {
o = s.taboption(tab, form.DummyValue, option, '');
o.rawhtml = true;
o.renderWidget = function() {
hpTcping.ensureStyle();
return E('div', {
'class': 'homeproxy-tcping-toolbar',
'style': 'display:flex; align-items:center; gap:.75em; flex-wrap:wrap;'
}, [
E('button', {
'class': 'cbi-button cbi-button-action homeproxy-tcping-button',
'title': '测速',
'type': 'button',
'click': (ev) => hpTcping.runNodes(collectNodeSections(grouphash), ev)
}, [ hpTcping.renderIcon('homeproxy-tcping-icon') ]),
E('span', {
'class': 'homeproxy-tcping-help',
'style': 'color:var(--text-color-medium, #777); line-height:1.4;'
}, hpTcping.helpText)
]);
};
};
m = new form.Map('homeproxy', _('Edit nodes'));
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
@@ -1215,9 +1439,18 @@ return view.extend({
/* Node settings start */
/* User nodes start */
s.tab('node', _('Nodes'));
addTcpingButton('node', '_tcping_node');
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
ss.addremove = true;
ss.handleRemove = function(section_id, ev) {
return showNodeReferenceNotice(section_id).then((blocked) => {
if (blocked)
return Promise.resolve();
return form.GridSection.prototype.handleRemove.apply(this, [ section_id, ev ]);
});
};
ss.filter = function(section_id) {
for (let info of subinfo)
if (info.hash === uci.get(data[0], section_id, 'grouphash'))
@@ -1320,6 +1553,7 @@ return view.extend({
/* Subscription nodes start */
for (const info of subinfo) {
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
addTcpingButton('sub_' + info.hash, '_tcping_' + info.hash, info.hash);
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
ss.filter = function(section_id) {
@@ -1386,6 +1620,11 @@ return view.extend({
o.rmempty = false;
o.onchange = allowInsecureConfirm;
o = s.taboption('subscription', form.Flag, 'allow_unsupported_tls_pin_fallback', _('Allow unsupported certificate pin fallback'),
_('When a subscription node uses a server certificate fingerprint but the current sing-box version cannot express it, skip the node by default. Enable this only if you explicitly accept falling back to TLS insecure mode for compatibility.'));
o.rmempty = false;
o.onchange = allowInsecureConfirm;
o = s.taboption('subscription', form.ListValue, 'packet_encoding', _('Default packet encoding'));
o.value('', _('none'));
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
@@ -1413,10 +1652,35 @@ return view.extend({
}
}
o.onclick = function() {
return fs.exec_direct('/etc/homeproxy/scripts/update_subscriptions.uc').then((res) => {
return location.reload();
ui.showModal(_('Updating subscriptions...'), [
E('p', _('Subscription update is running. The page will refresh automatically when it finishes.'))
]);
return callUpdateSubscriptions().catch((err) => {
return {
result: false,
error: subscriptionRpcErrorMessage(err, _('An error occurred while updating subscriptions.'))
};
}).then((res) => {
if (!res.result) {
ui.hideModal();
ui.addNotification(null, E('p', res.error || _('An error occurred while updating subscriptions.')), 'warning');
return this.map.reset();
}
return pollSubscriptionUpdateStatus(0).then((status) => {
ui.hideModal();
if (!status.update_result) {
ui.addNotification(null, E('p', status.error || _('An error occurred while updating subscriptions.')), 'warning');
return this.map.reset();
}
return location.reload();
});
}).catch((err) => {
ui.addNotification(null, E('p', _('An error occurred during updating subscriptions: %s').format(err)));
ui.hideModal();
ui.addNotification(null, E('p', subscriptionRpcErrorMessage(err, _('An error occurred while updating subscriptions.'))), 'warning');
return this.map.reset();
});
}
@@ -1444,19 +1708,17 @@ return view.extend({
subnodes = subnodes.concat(res['.name'])
});
for (let i in subnodes)
uci.remove(data[0], subnodes[i]);
return L.resolveDefault(callRemoveSubscriptionNodes(subnodes), { result: false, error: _('Unknown error.') }).then((res) => {
if (!res.result)
throw new Error(res.error || _('Unknown error.'));
if (subnodes.includes(uci.get(data[0], 'config', 'main_node')))
uci.set(data[0], 'config', 'main_node', 'nil');
if (subnodes.includes(uci.get(data[0], 'config', 'main_udp_node')))
uci.set(data[0], 'config', 'main_udp_node', 'nil');
this.inputtitle = _('%s nodes removed').format(subnodes.length);
this.readonly = true;
return this.map.save(null, true);
this.inputtitle = _('%s nodes removed').format(res.removed || 0);
this.readonly = true;
return location.reload();
}).catch((err) => {
ui.addNotification(null, E('p', _('An error occurred during removing subscription nodes: %s').format(err.message || err)));
return this.map.reset();
});
}
/* Subscriptions settings end */
File renamed without changes.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -33,6 +33,15 @@ config homeproxy 'config'
option ipv6_support '1'
option github_token ''
option log_level 'warn'
option clash_api_enabled '1'
option clash_api_external_controller '127.0.0.1:9090'
option clash_api_default_mode 'Rule'
list clash_api_allow_origin 'https://metacubex.github.io'
list clash_api_allow_origin 'https://metacubexd.pages.dev'
list clash_api_allow_origin 'http://d.metacubex.one'
list clash_api_allow_origin 'https://yacd.metacubex.one'
option clash_api_allow_private_network '1'
option metacubexd_url 'https://metacubexd.pages.dev/#/overview'
config homeproxy 'control'
option lan_proxy_mode 'disabled'
@@ -66,6 +75,7 @@ config homeproxy 'dns'
config homeproxy 'subscription'
option auto_update '0'
option allow_insecure '0'
option allow_unsupported_tls_pin_fallback '0'
option packet_encoding 'xudp'
option update_via_proxy '0'
option filter_nodes 'blacklist'
@@ -75,4 +85,3 @@ config homeproxy 'subscription'
config homeproxy 'server'
option enabled '0'
option log_level 'warn'
@@ -57,17 +57,25 @@ const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
let outbound_node, outbound_udp_node, default_outbound, china_dns_server, bypass_cn_traffic;
default_outbound = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
if (routing_mode !== 'custom') {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
if (isEmpty(outbound_node))
outbound_node = default_outbound;
} else {
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
outbound_node = default_outbound;
}
const use_default_outbound_routing = (routing_mode !== 'custom') &&
isEmpty(uci.get(cfgname, 'config', 'main_node') || 'nil') &&
!isEmpty(default_outbound);
let routing_port = uci.get(cfgname, 'config', 'routing_port');
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
@@ -84,7 +92,7 @@ if (match(proxy_mode, /redirect/)) {
redirect_port = uci.get(cfgname, 'infra', 'redirect_port') || '5331';
}
if (match(proxy_mode, /tproxy/))
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
if (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing) {
tproxy_port = uci.get(cfgname, 'infra', 'tproxy_port') || '5332';
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101');
}
@@ -376,7 +384,7 @@ chain homeproxy_output_redir {
{% endif %}
{# UDP tproxy #}
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %}
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing)): %}
chain homeproxy_mangle_tproxy {
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
{% if (ipv6_support === '1'): %}
@@ -15,10 +15,9 @@ const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainl
let outbound_node, tun_name;
if (match(proxy_mode, /tun/)) {
if (routing_mode === 'custom')
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node === 'nil')
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
else
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node !== 'nil')
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
@@ -14,15 +14,83 @@ import { cursor } from 'uci';
import {
isEmpty, parseURL, strToBool, strToInt, strToTime,
removeBlankAttrs, validation, HP_DIR, RUN_DIR
removeBlankAttrs, validation, HP_DIR as DEFAULT_HP_DIR, RUN_DIR as DEFAULT_RUN_DIR
} from 'homeproxy';
import {
apply_outbound_tag_rename as applyOutboundTagRenameHelper,
assert_unique_outbound_tags as assertUniqueOutboundTagsHelper,
assert_no_stale_outbound_tags as assertNoStaleOutboundTagsHelper,
build_outbound_tag_map,
get_outbound_tag,
get_shadowtls_outbound_tag,
get_fallback_outbound_tag
} from 'outbound_tag';
import { expand_node_filter as expandNodeFilterHelper } from 'node_filter';
import * as routingTarget from 'routing_target';
const ubus = connect();
function testRootArg() {
const argv = (type(ARGV) === 'array') ? ARGV : [];
for (let i = 0; i < length(argv); i++)
if (argv[i] === '--test-root' && !isEmpty(argv[i + 1]))
return argv[i + 1];
return null;
}
const TEST_ROOT = testRootArg();
const HP_DIR = TEST_ROOT ? TEST_ROOT + '/etc/homeproxy' : DEFAULT_HP_DIR;
const RUN_DIR = TEST_ROOT ? TEST_ROOT + '/var/run/homeproxy' : DEFAULT_RUN_DIR;
const DIAGNOSTICS_PATH = RUN_DIR + '/config-diagnostics.json';
/* 配置错误与诊断收集 */
let config_errors = [];
function reportError(type, message, suggestion) {
push(config_errors, {
type: type, /* 'error', 'warning' */
message: message,
suggestion: suggestion
});
}
function hasErrors() {
for (let err in config_errors)
if (err.type === 'error')
return true;
return false;
}
function formatErrors() {
let output = '';
for (let err in config_errors) {
output += sprintf('[%s] %s\n', uc(err.type), err.message);
if (err.suggestion)
output += sprintf('建议: %s\n', err.suggestion);
output += '\n';
}
return output;
}
function writeDiagnostics() {
system('mkdir -p ' + RUN_DIR);
if (length(config_errors)) {
writefile(DIAGNOSTICS_PATH, sprintf('%.J\n', {
time: time(),
items: config_errors
}));
} else {
system('rm -f ' + DIAGNOSTICS_PATH);
}
}
const ubus = TEST_ROOT ? null : connect();
/* const features = ubus.call('luci.homeproxy', 'singbox_get_features') || {}; */
/* UCI config start */
const uci = cursor();
const uci = TEST_ROOT ? cursor(TEST_ROOT + '/etc/config') : cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
@@ -43,8 +111,17 @@ const ucinode = 'node';
const uciruleset = 'ruleset';
const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainland_china';
const routing_target_max_depth = 20;
const routing_target_ctx = {
uci: uci,
config: uciconfig,
node_type: ucinode,
tag_map: build_outbound_tag_map(uci),
max_depth: routing_target_max_depth,
reportError: reportError
};
let wan_dns = ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0];
let wan_dns = TEST_ROOT ? null : ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0];
if (!wan_dns)
wan_dns = (routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5';
@@ -60,6 +137,20 @@ let main_node, main_udp_node, dedicated_udp_node, default_outbound, default_outb
dns_client_subnet, cache_file_store_rdrc, cache_file_rdrc_timeout, direct_domain_list,
proxy_domain_list;
/* Routing / DNS advanced settings may still be in use when非 custom模式下 main_node 为空 */
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');
dns_default_strategy = uci.get(uciconfig, ucidnssetting, 'default_strategy') ||
uci.get(uciconfig, ucidnssetting, 'dns_strategy');
dns_default_server = uci.get(uciconfig, ucidnssetting, 'default_server') || 'default-dns';
dns_disable_cache = uci.get(uciconfig, ucidnssetting, 'disable_cache');
dns_disable_cache_expire = uci.get(uciconfig, ucidnssetting, 'disable_cache_expire');
dns_independent_cache = uci.get(uciconfig, ucidnssetting, 'independent_cache');
dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet');
cache_file_store_rdrc = uci.get(uciconfig, ucidnssetting, 'cache_file_store_rdrc');
cache_file_rdrc_timeout = uci.get(uciconfig, ucidnssetting, 'cache_file_rdrc_timeout');
if (routing_mode !== 'custom') {
main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil';
main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil';
@@ -74,7 +165,8 @@ if (routing_mode !== 'custom') {
if (isEmpty(china_dns_server) || type(china_dns_server) !== 'string' || china_dns_server === 'wan')
china_dns_server = wan_dns;
}
dns_default_strategy = (ipv6_support !== '1') ? 'ipv4_only' : null;
if (!isEmpty(main_node))
dns_default_strategy = (ipv6_support !== '1') ? 'ipv4_only' : null;
direct_domain_list = trim(readfile(HP_DIR + '/resources/direct_list.txt'));
if (direct_domain_list)
@@ -84,25 +176,14 @@ if (routing_mode !== 'custom') {
if (proxy_domain_list)
proxy_domain_list = split(proxy_domain_list, /[\r\n]/);
sniff_override = uci.get(uciconfig, uciinfra, 'sniff_override') || '1';
sniff_override = uci.get(uciconfig, uciinfra, 'sniff_override') ||
uci.get(uciconfig, uciroutingsetting, 'sniff_override') || '1';
} 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_independent_cache = uci.get(uciconfig, ucidnssetting, 'independent_cache');
dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet');
cache_file_store_rdrc = uci.get(uciconfig, ucidnssetting, 'cache_file_store_rdrc'),
cache_file_rdrc_timeout = uci.get(uciconfig, ucidnssetting, 'cache_file_rdrc_timeout');
/* 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');
sniff_override = uci.get(uciconfig, uciroutingsetting, 'sniff_override');
}
const use_default_outbound_routing = isEmpty(main_node) && !isEmpty(default_outbound);
const proxy_mode = uci.get(uciconfig, ucimain, 'proxy_mode') || 'redirect_tproxy',
default_interface = uci.get(uciconfig, ucicontrol, 'bind_interface');
@@ -122,7 +203,7 @@ if (match(proxy_mode, /redirect/)) {
redirect_port = uci.get(uciconfig, 'infra', 'redirect_port') || '5331';
}
if (match(proxy_mode, /tproxy/))
if (main_udp_node !== 'nil' || routing_mode === 'custom')
if (main_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing)
tproxy_port = uci.get(uciconfig, 'infra', 'tproxy_port') || '5332';
if (match(proxy_mode, /tun/)) {
tun_name = uci.get(uciconfig, uciinfra, 'tun_name') || 'singtun0';
@@ -137,6 +218,14 @@ if (match(proxy_mode, /tun/)) {
}
const log_level = uci.get(uciconfig, ucimain, 'log_level') || 'warn';
const clash_api_enabled = uci.get(uciconfig, ucimain, 'clash_api_enabled') || '0',
clash_api_external_controller = uci.get(uciconfig, ucimain, 'clash_api_external_controller') || '127.0.0.1:9090',
clash_api_secret = uci.get(uciconfig, ucimain, 'clash_api_secret'),
clash_api_default_mode = uci.get(uciconfig, ucimain, 'clash_api_default_mode') || 'Rule',
clash_api_allow_origin = uci.get(uciconfig, ucimain, 'clash_api_allow_origin') || [],
clash_api_allow_private_network = uci.get(uciconfig, ucimain, 'clash_api_allow_private_network') || '1';
/* UCI config end */
/* Config helper start */
@@ -180,13 +269,36 @@ function parse_dnsquery(strquery) {
}
function generate_endpoint(node) {
function section_outbound_tag(section) {
return get_fallback_outbound_tag(section);
}
function node_outbound_tag(node_or_section) {
let section = (type(node_or_section) === 'object') ? node_or_section['.name'] : node_or_section;
return section_outbound_tag(section);
}
function runtime_outbound_tag(node_or_section) {
let section = (type(node_or_section) === 'object') ? node_or_section['.name'] : node_or_section;
return get_outbound_tag(routing_target_ctx.tag_map, section);
}
function runtime_shadowtls_tag(node_or_section) {
let section = (type(node_or_section) === 'object') ? node_or_section['.name'] : node_or_section;
return get_shadowtls_outbound_tag(routing_target_ctx.tag_map, section);
}
function map_outbound_tags(node_ids) {
return map(node_ids, (node_id) => node_outbound_tag(node_id));
}
function generate_endpoint(node, tag) {
if (type(node) !== 'object' || isEmpty(node))
return null;
const endpoint = {
type: node.type,
tag: 'cfg-' + node['.name'] + '-out',
tag: tag || node_outbound_tag(node),
address: node.wireguard_local_address,
mtu: strToInt(node.wireguard_mtu),
private_key: node.wireguard_private_key,
@@ -213,13 +325,13 @@ function generate_endpoint(node) {
return endpoint;
}
function generate_outbound(node) {
function generate_outbound(node, tag) {
if (type(node) !== 'object' || isEmpty(node))
return null;
const outbound = {
type: node.type,
tag: 'cfg-' + node['.name'] + '-out',
tag: tag || node_outbound_tag(node),
routing_mark: strToInt(self_mark),
server: node.address,
@@ -278,7 +390,6 @@ function generate_outbound(node) {
global_padding: strToBool(node.vmess_global_padding),
authenticated_length: strToBool(node.vmess_authenticated_length),
packet_encoding: node.packet_encoding,
multiplex: (node.multiplex === '1') ? {
enabled: true,
protocol: node.multiplex_protocol,
@@ -343,33 +454,171 @@ function generate_outbound(node) {
return outbound;
}
function get_outbound(cfg) {
if (isEmpty(cfg))
function generate_shadowtls_outbound(node, tag) {
if (type(node) !== 'object' || node.shadowtls_enabled === '0' || isEmpty(node.shadowtls_address))
return null;
if (type(cfg) === 'array') {
if ('any-out' in cfg)
return 'any';
let outbounds = [];
for (let i in cfg)
push(outbounds, get_outbound(i));
return outbounds;
} else {
switch (cfg) {
case 'block-out':
case 'direct-out':
return cfg;
default:
const node = uci.get(uciconfig, cfg, 'node');
if (isEmpty(node))
die(sprintf("%s's node is missing, please check your configuration.", cfg));
else if (node === 'urltest')
return 'cfg-' + cfg + '-out';
else
return 'cfg-' + node + '-out';
return {
type: 'shadowtls',
tag: tag,
routing_mark: strToInt(self_mark),
server: node.shadowtls_address,
server_port: strToInt(node.shadowtls_port),
version: strToInt(node.shadowtls_version),
password: node.shadowtls_password,
tls: {
enabled: true,
server_name: node.shadowtls_sni
}
};
}
function has_shadowtls_detour(node) {
return type(node) === 'object' &&
node.type === 'shadowsocks' &&
node.shadowtls_enabled !== '0' &&
!isEmpty(node.shadowtls_address);
}
function apply_routing_node_options(outbound, cfg) {
if (type(outbound) !== 'object' || type(cfg) !== 'object' || isEmpty(cfg))
return;
outbound.bind_interface = cfg.bind_interface;
if (cfg.outbound) {
if (routingTarget.routing_node_has_path(routing_target_ctx, cfg.outbound, cfg['.name'], {}))
reportError('error',
sprintf('路由节点配置错误:%s 的上游出站与自身形成循环引用。', cfg.label || cfg['.name']),
'进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,检查该节点的"上游出站"配置');
else
outbound.detour = get_outbound(cfg.outbound, 'block-out',
sprintf('路由节点 %s 的上游出站', cfg.label || cfg['.name']));
}
if (cfg.domain_resolver)
outbound.domain_resolver = {
server: get_resolver(cfg.domain_resolver),
strategy: cfg.domain_strategy
};
}
function push_node_outbound(client_config, node, tag, routing_cfg) {
if (type(node) !== 'object' || isEmpty(node))
return;
if (node.type === 'wireguard') {
push(client_config.endpoints, generate_endpoint(node, tag));
apply_routing_node_options(client_config.endpoints[length(client_config.endpoints)-1], routing_cfg);
} else {
let outbound = generate_outbound(node, tag);
if (has_shadowtls_detour(node)) {
const shadowtls_tag = (tag === node_outbound_tag(node))
? runtime_shadowtls_tag(node)
: tag + '-shadowtls';
let shadowtls_outbound = generate_shadowtls_outbound(node, shadowtls_tag);
apply_routing_node_options(shadowtls_outbound, routing_cfg);
outbound.detour = shadowtls_tag;
push(client_config.outbounds, shadowtls_outbound);
} else {
apply_routing_node_options(outbound, routing_cfg);
}
push(client_config.outbounds, outbound);
}
}
function push_block_outbound(client_config, tag) {
push(client_config.outbounds, {
type: 'block',
tag: tag
});
}
function is_builtin_outbound(target) {
return routingTarget.is_builtin_outbound(routing_target_ctx, target);
}
function is_node_section(target) {
return routingTarget.is_node_section(routing_target_ctx, target);
}
function is_routing_node_section(target) {
return routingTarget.is_routing_node_section(routing_target_ctx, target);
}
function expand_node_filter(manual_nodes, node_filter, node_filter_exclude, owner, allow_routing_node) {
let result = expandNodeFilterHelper({
uci: uci,
config: uciconfig,
node_type: ucinode,
allow_manual_node: (node_id) => {
if (is_node_section(node_id) ||
(allow_routing_node && (is_builtin_outbound(node_id) || is_routing_node_section(node_id))))
return true;
return false;
},
on_invalid_manual_node: (node_id) => {
reportError('warning',
sprintf('节点组 %s 引用了已删除的节点:%s,已在本次生成中跳过。', owner, node_id),
'请进入 LuCI 界面检查对应的节点列表引用');
}
}, manual_nodes, node_filter, node_filter_exclude);
if (!result.result) {
if (!isEmpty(node_filter) && !isEmpty(node_filter_exclude)) {
reportError('error',
sprintf('路由节点 %s 的节点正则或排除正则无效:%s', owner, result.error),
'请修正节点正则/排除正则,或清空该字段后重新生成配置');
} else if (!isEmpty(node_filter)) {
reportError('error',
sprintf('路由节点 %s 的节点正则无效:%s', owner, result.error),
'请修正节点正则,或清空该字段后重新生成配置');
} else {
reportError('error',
sprintf('路由节点 %s 的排除正则无效:%s', owner, result.error),
'请修正排除正则,或清空该字段后重新生成配置');
}
return [];
}
if (result.truncated)
reportError('warning',
sprintf('路由节点 %s 的节点正则命中超过 %d 个节点,已截断结果。', owner, result.max_result_nodes),
'请收窄节点正则或拆分路由节点,避免一次生成过大的 outbound 组');
if (result.scan_truncated)
reportError('warning',
sprintf('路由节点 %s 的节点正则扫描超过 %d 个节点,已停止继续匹配。', owner, result.max_scan_nodes),
'请收窄订阅节点规模或正则范围,避免生成器长时间执行复杂匹配');
return result.nodes || [];
}
function resolve_outbound_target(target, owner, seen_path) {
return routingTarget.resolve_outbound_target(routing_target_ctx, target, owner, seen_path);
}
function get_routing_target_outbound(target, owner) {
return routingTarget.get_routing_target_outbound(routing_target_ctx, target, owner);
}
function push_routing_target_outbound(client_config, target, routing_nodes) {
return routingTarget.collect_routing_target_dependencies(
routing_target_ctx,
client_config,
target,
routing_nodes,
push_node_outbound,
node_outbound_tag
);
}
function get_valid_selector_outbounds(selector_nodes, owner, owner_section) {
return routingTarget.get_valid_selector_outbounds(routing_target_ctx, selector_nodes, owner, owner_section);
}
function get_outbound(cfg, fallback, owner) {
return routingTarget.get_outbound(routing_target_ctx, cfg, fallback, owner);
}
function get_resolver(cfg) {
@@ -394,6 +643,26 @@ function get_ruleset(cfg) {
push(rules, isEmpty(i) ? null : 'cfg-' + i + '-rule');
return rules;
}
function apply_outbound_tag_rename(client_config) {
return applyOutboundTagRenameHelper(client_config, routing_target_ctx.tag_map);
}
function assert_unique_outbound_tags(client_config) {
return assertUniqueOutboundTagsHelper(client_config, (tag) => {
reportError('error',
sprintf('rename 后出现重复 outbound tag: %s', tag),
'通常意味着去重算法异常;请附带节点 label / section 列表反馈');
});
}
function assert_no_stale_outbound_tags(client_config) {
return assertNoStaleOutboundTagsHelper(client_config, routing_target_ctx.tag_map, (old_tag, final_tag) => {
reportError('error',
sprintf('rename 后仍残留旧 outbound tag 引用: %s -> %s', old_tag, final_tag),
'请检查新增 sing-box 字段是否需要纳入 outbound tag rename 规则');
});
}
/* Config helper end */
const config = {};
@@ -514,7 +783,8 @@ if (!isEmpty(main_node)) {
if (cfg.enabled !== '1')
return;
let outbound = get_outbound(cfg.outbound);
let outbound = get_outbound(cfg.outbound, 'block-out',
sprintf('DNS Server %s 的出站', cfg.label || cfg['.name']));
if (outbound === 'direct-out' && isEmpty(self_mark))
outbound = null;
@@ -567,7 +837,8 @@ if (!isEmpty(main_node)) {
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
rule_set_ip_cidr_accept_empty: strToBool(cfg.rule_set_ip_cidr_accept_empty),
invert: strToBool(cfg.invert),
outbound: get_outbound(cfg.outbound),
outbound: get_outbound(cfg.outbound, 'block-out',
sprintf('DNS 规则 %s 的出站匹配', cfg.label || cfg['.name'])),
action: cfg.action,
server: get_resolver(cfg.server),
strategy: cfg.domain_strategy,
@@ -621,7 +892,7 @@ if (match(proxy_mode, /redirect/))
sniff: true,
sniff_override_destination: strToBool(sniff_override)
});
if (match(proxy_mode, /tproxy/))
if (match(proxy_mode, /tproxy/) && !isEmpty(tproxy_port))
push(config.inbounds, {
type: 'tproxy',
tag: 'tproxy-in',
@@ -671,67 +942,87 @@ if (!isEmpty(main_node)) {
let urltest_nodes = [];
if (main_node === 'urltest') {
const main_urltest_nodes = uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [];
const main_urltest_nodes = expand_node_filter(
uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [],
null,
null,
'main_node',
false);
const main_urltest_interval = uci.get(uciconfig, ucimain, 'main_urltest_interval');
const main_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_urltest_tolerance');
push(config.outbounds, {
type: 'urltest',
tag: 'main-out',
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_urltest_interval),
tolerance: strToInt(main_urltest_tolerance),
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
});
if (length(main_urltest_nodes)) {
push(config.outbounds, {
type: 'urltest',
tag: 'main-out',
outbounds: map_outbound_tags(main_urltest_nodes),
interval: strToTime(main_urltest_interval),
tolerance: strToInt(main_urltest_tolerance),
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
});
} else {
reportError('warning',
'主节点 URLTest 列表没有可用节点,已在本次生成中临时回退为阻断出站。',
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主节点 URLTest 列表');
push_block_outbound(config, 'main-out');
}
urltest_nodes = main_urltest_nodes;
} else {
const main_node_cfg = uci.get_all(uciconfig, main_node) || {};
if (main_node_cfg.type === 'wireguard') {
push(config.endpoints, generate_endpoint(main_node_cfg));
config.endpoints[length(config.endpoints)-1].tag = 'main-out';
if (is_node_section(main_node)) {
push_node_outbound(config, main_node_cfg, 'main-out');
} else {
push(config.outbounds, generate_outbound(main_node_cfg));
config.outbounds[length(config.outbounds)-1].tag = 'main-out';
reportError('warning',
sprintf('主节点引用了已删除的节点:%s,已在本次生成中临时回退为阻断出站。', main_node),
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主节点');
push_block_outbound(config, 'main-out');
}
}
if (main_udp_node === 'urltest') {
const main_udp_urltest_nodes = uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [];
const main_udp_urltest_nodes = expand_node_filter(
uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [],
null,
null,
'main_udp_node',
false);
const main_udp_urltest_interval = uci.get(uciconfig, ucimain, 'main_udp_urltest_interval');
const main_udp_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_udp_urltest_tolerance');
push(config.outbounds, {
type: 'urltest',
tag: 'main-udp-out',
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_udp_urltest_interval),
tolerance: strToInt(main_udp_urltest_tolerance),
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
});
if (length(main_udp_urltest_nodes)) {
push(config.outbounds, {
type: 'urltest',
tag: 'main-udp-out',
outbounds: map_outbound_tags(main_udp_urltest_nodes),
interval: strToTime(main_udp_urltest_interval),
tolerance: strToInt(main_udp_urltest_tolerance),
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
});
} else {
reportError('warning',
'主 UDP 节点 URLTest 列表没有可用节点,已在本次生成中临时回退为阻断出站。',
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主 UDP 节点 URLTest 列表');
push_block_outbound(config, 'main-udp-out');
}
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else if (dedicated_udp_node) {
const main_udp_node_cfg = uci.get_all(uciconfig, main_udp_node) || {};
if (main_udp_node_cfg.type === 'wireguard') {
push(config.endpoints, generate_endpoint(main_udp_node_cfg));
config.endpoints[length(config.endpoints)-1].tag = 'main-udp-out';
if (is_node_section(main_udp_node)) {
push_node_outbound(config, main_udp_node_cfg, 'main-udp-out');
} else {
push(config.outbounds, generate_outbound(main_udp_node_cfg));
config.outbounds[length(config.outbounds)-1].tag = 'main-udp-out';
reportError('warning',
sprintf('主 UDP 节点引用了已删除的节点:%s,已在本次生成中临时回退为阻断出站。', main_udp_node),
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主 UDP 节点');
push_block_outbound(config, 'main-udp-out');
}
}
for (let i in urltest_nodes) {
const urltest_node = uci.get_all(uciconfig, i) || {};
if (urltest_node.type === 'wireguard') {
push(config.endpoints, generate_endpoint(urltest_node));
config.endpoints[length(config.endpoints)-1].tag = 'cfg-' + i + '-out';
} else {
push(config.outbounds, generate_outbound(urltest_node));
config.outbounds[length(config.outbounds)-1].tag = 'cfg-' + i + '-out';
}
push_node_outbound(config, urltest_node, node_outbound_tag(i));
}
} else if (!isEmpty(default_outbound)) {
let urltest_nodes = [],
let group_nodes = [],
routing_nodes = [];
uci.foreach(uciconfig, uciroutingnode, (cfg) => {
@@ -739,49 +1030,80 @@ if (!isEmpty(main_node)) {
return;
if (cfg.node === 'urltest') {
const owner = cfg.label || cfg['.name'];
const urltest_nodes = expand_node_filter(cfg.urltest_nodes, cfg.node_filter, cfg.node_filter_exclude, owner, false);
if (!length(urltest_nodes)) {
reportError('warning',
sprintf('路由节点 %s 没有可用节点,已在本次生成中临时回退为阻断出站。', owner),
'请手动选择节点,或调整节点正则/排除正则以命中可用节点');
push_block_outbound(config, node_outbound_tag(cfg));
return;
}
push(config.outbounds, {
type: 'urltest',
tag: 'cfg-' + cfg['.name'] + '-out',
outbounds: map(cfg.urltest_nodes, (k) => `cfg-${k}-out`),
tag: node_outbound_tag(cfg),
outbounds: map_outbound_tags(urltest_nodes),
url: cfg.urltest_url,
interval: strToTime(cfg.urltest_interval),
tolerance: strToInt(cfg.urltest_tolerance),
idle_timeout: strToTime(cfg.urltest_idle_timeout),
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
});
urltest_nodes = [...urltest_nodes, ...filter(cfg.urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else {
const outbound = uci.get_all(uciconfig, cfg.node) || {};
if (outbound.type === 'wireguard') {
push(config.endpoints, generate_endpoint(outbound));
config.endpoints[length(config.endpoints)-1].bind_interface = cfg.bind_interface;
config.endpoints[length(config.endpoints)-1].detour = get_outbound(cfg.outbound);
if (cfg.domain_resolver)
config.endpoints[length(config.endpoints)-1].domain_resolver = {
server: get_resolver(cfg.domain_resolver),
strategy: cfg.domain_strategy
};
} else {
push(config.outbounds, generate_outbound(outbound));
config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface;
config.outbounds[length(config.outbounds)-1].detour = get_outbound(cfg.outbound);
if (cfg.domain_resolver)
config.outbounds[length(config.outbounds)-1].domain_resolver = {
server: get_resolver(cfg.domain_resolver),
strategy: cfg.domain_strategy
};
group_nodes = [...group_nodes, ...filter(urltest_nodes, (l) => !~index(group_nodes, l))];
} else if (cfg.node === 'selector') {
const owner = cfg.label || cfg['.name'];
const selector_nodes = expand_node_filter(cfg.selector_nodes, cfg.node_filter, cfg.node_filter_exclude, owner, true);
const selector_outbounds = get_valid_selector_outbounds(selector_nodes, owner, cfg['.name']);
if (!length(selector_outbounds)) {
reportError('warning',
sprintf('路由节点 %s 没有可用节点,已在本次生成中临时回退为阻断出站。', owner),
'请手动选择节点,或调整节点正则/排除正则以命中可用节点');
push_block_outbound(config, node_outbound_tag(cfg));
return;
}
push(routing_nodes, cfg.node);
let selector_default = get_routing_target_outbound(
cfg.selector_default,
sprintf('路由节点 %s 的默认 Selector 节点', owner));
if (!isEmpty(selector_default) && !~index(selector_outbounds, selector_default))
selector_default = null;
push(config.outbounds, {
type: 'selector',
tag: node_outbound_tag(cfg),
outbounds: selector_outbounds,
default: selector_default,
interrupt_exist_connections: strToBool(cfg.selector_interrupt_exist_connections)
});
group_nodes = [...group_nodes, ...filter(selector_nodes, (l) => !~index(group_nodes, l))];
} else {
const resolved = resolve_outbound_target(
cfg.node,
sprintf('路由节点 %s 的节点', cfg.label || cfg['.name']),
[]);
if (!resolved || resolved.fatal || resolved.type !== 'node') {
reportError('warning',
sprintf('路由节点 %s 的节点引用已失效,已在本次生成中临时回退为阻断出站。', cfg.label || cfg['.name']),
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,重新选择有效节点');
push_block_outbound(config, node_outbound_tag(cfg));
return;
}
const outbound = uci.get_all(uciconfig, resolved.node_id) || {};
push_node_outbound(config, outbound, node_outbound_tag(resolved.node_id), cfg);
push(routing_nodes, resolved.node_id);
}
});
for (let i in filter(urltest_nodes, (l) => !~index(routing_nodes, l))) {
const urltest_node = uci.get_all(uciconfig, i) || {};
if (urltest_node.type === 'wireguard')
push(config.endpoints, generate_endpoint(urltest_node));
else
push(config.outbounds, generate_outbound(urltest_node));
}
for (let i in filter(group_nodes, (l) => !~index(routing_nodes, l)))
push_routing_target_outbound(config, i, routing_nodes);
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
if (cfg.enabled === '1' && cfg.action === 'route')
push_routing_target_outbound(config, cfg.outbound, routing_nodes);
});
}
if (isEmpty(config.endpoints))
@@ -926,7 +1248,8 @@ if (!isEmpty(main_node)) {
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
invert: strToBool(cfg.invert),
action: cfg.action,
outbound: get_outbound(cfg.outbound),
outbound: get_outbound(cfg.outbound, 'block-out',
sprintf('路由规则 %s 的出站', cfg.label || cfg['.name'])),
override_address: cfg.override_address,
override_port: strToInt(cfg.override_port),
udp_disable_domain_unmapping: strToBool(cfg.udp_disable_domain_unmapping),
@@ -938,7 +1261,7 @@ if (!isEmpty(main_node)) {
});
});
config.route.final = get_outbound(default_outbound);
config.route.final = get_outbound(default_outbound, 'block-out', '默认出站');
/* Rule set */
uci.foreach(uciconfig, uciruleset, (cfg) => {
@@ -951,7 +1274,8 @@ if (!isEmpty(main_node)) {
format: cfg.format,
path: cfg.path,
url: cfg.url,
download_detour: get_outbound(cfg.outbound),
download_detour: get_outbound(cfg.outbound, 'block-out',
sprintf('规则集 %s 的下载出站', cfg.label || cfg['.name'])),
update_interval: cfg.update_interval
});
});
@@ -959,17 +1283,44 @@ if (!isEmpty(main_node)) {
/* Routing rules end */
/* Experimental start */
if (routing_mode in ['bypass_mainland_china', 'custom']) {
config.experimental = {
cache_file: {
config.experimental = {};
if (routing_mode in ['bypass_mainland_china', 'custom'])
config.experimental.cache_file = {
enabled: true,
path: RUN_DIR + '/cache.db',
store_rdrc: strToBool(cache_file_store_rdrc),
rdrc_timeout: strToTime(cache_file_rdrc_timeout),
}
};
if (strToBool(clash_api_enabled))
config.experimental.clash_api = {
external_controller: clash_api_external_controller,
secret: clash_api_secret,
default_mode: clash_api_default_mode,
access_control_allow_origin: clash_api_allow_origin,
access_control_allow_private_network: strToBool(clash_api_allow_private_network)
};
}
if (isEmpty(config.experimental))
config.experimental = null;
/* Experimental end */
apply_outbound_tag_rename(config);
assert_unique_outbound_tags(config);
assert_no_stale_outbound_tags(config);
writeDiagnostics();
/*
* fatal 级配置错误不能用无效配置覆盖现有 sing-box-c.json,否则 init 脚本中的
* sing-box check 会失败并拖垮正在运行的代理。这里保留上一份有效配置,只报告
* 聚合错误并退出非零,让服务继续使用上一份有效配置。
*/
if (hasErrors()) {
warn('HomeProxy 配置验证发现以下问题:\n\n' + formatErrors());
warn('为避免用无效配置覆盖当前可用配置,本次未写入新配置;服务将继续使用上次的有效配置。请修复上述问题后重启服务。\n');
exit(1);
}
system('mkdir -p ' + RUN_DIR);
writefile(RUN_DIR + '/sing-box-c.json', sprintf('%.J\n', removeBlankAttrs(config)));
@@ -67,14 +67,36 @@ export function getTime(epoch) {
};
export function wGET(url, ua) {
const result = wGETResult(url, ua);
return result?.stdout;
};
export function wGETResult(url, ua) {
if (!url || type(url) !== 'string')
return null;
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
const output = executeCommand(`/usr/bin/wget -qO- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
return trim(output.stdout);
let last_result = null;
for (let extra in [ '', '-4' ]) {
const output = executeCommand(`/usr/bin/wget -q ${extra} -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
output.stdout = trim(output.stdout);
output.stderr = trim(output.stderr);
last_result = output;
if (output.exitcode === 0 && output.stdout)
return output;
}
if (last_result) {
last_result.stdout = trim(last_result.stdout);
last_result.stderr = trim(last_result.stderr);
}
return last_result;
};
/* Utilities end */
@@ -8,7 +8,14 @@
'use strict';
import { cursor } from 'uci';
import { isEmpty, parseURL, validation } from 'homeproxy';
import { executeCommand, isEmpty, parseURL, validation } from 'homeproxy';
import {
format_controller,
is_portable_bind_host,
local_ipv4_addresses,
normalize_local_controller_option,
parse_controller
} from '/etc/homeproxy/scripts/clash_api.uc';
const uci = cursor();
@@ -25,8 +32,37 @@ const uciinfra = 'infra',
ucirouting = 'routing',
uciroutingnode = 'routing_node',
uciroutingrule = 'routing_rule',
ucisubscription = 'subscription',
uciserver = 'server';
/* Generate a random 256-bit hex secret for the Clash API. Returns null when no
* usable randomness source is available, in which case the caller leaves the
* secret unset rather than installing a predictable value. */
function generateClashSecret() {
let secret = trim(executeCommand("head -c 32 /dev/urandom | hexdump -v -e '1/1 \"%02x\"'")?.stdout || '');
if (!match(secret, /^[a-f0-9]{64}$/))
secret = trim(executeCommand('tr -dc a-f0-9 < /dev/urandom | head -c 64')?.stdout || '');
return match(secret, /^[a-f0-9]{32,}$/) ? secret : null;
}
function normalizeLocalControllerOptions() {
let local_addresses = local_ipv4_addresses((command) => executeCommand(command)?.stdout || ''),
fallback_host = local_addresses[0];
if (isEmpty(fallback_host))
return;
/* clash_api_external_controller is intentionally pinned to loopback (see
* below) and must NOT be rewritten to the LAN address. Only the LAN-facing
* display proxy is bound to a reachable local address here. */
for (let item in [
[ 'clash_api_proxy_external_controller', 9091, true ]
])
normalize_local_controller_option(uci, uciconfig, ucimain,
local_addresses, fallback_host, item[0], item[1], item[2]);
}
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
@@ -75,6 +111,56 @@ if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
if (isEmpty(uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback')))
uci.set(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback', '0');
/* Clash API dashboard integration */
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_enabled')))
uci.set(uciconfig, ucimain, 'clash_api_enabled', '1');
/* Bind sing-box's own Clash API controller to loopback so it is never
* reachable directly from the LAN; dashboards reach it only through the
* filtering display proxy. Existing LAN-bound controllers are migrated to
* 127.0.0.1 once (port preserved); a later deliberate change is left alone. */
const clash_external = uci.get(uciconfig, ucimain, 'clash_api_external_controller');
if (isEmpty(clash_external)) {
uci.set(uciconfig, ucimain, 'clash_api_external_controller', '127.0.0.1:9090');
} else if (isEmpty(uci.get(uciconfig, ucimigration, 'clash_api_localhost'))) {
const clash_controller = parse_controller(clash_external, 9090);
if (!is_portable_bind_host(clash_controller.host))
uci.set(uciconfig, ucimain, 'clash_api_external_controller',
format_controller('127.0.0.1', clash_controller.port || 9090));
}
uci.set(uciconfig, ucimigration, 'clash_api_localhost', '1');
/* sing-box's Clash API performs NO authentication when the secret is empty,
* and the LAN-facing display proxy forwards to it, so anyone able to reach the
* proxy could otherwise control the proxy without credentials. Generate a
* random secret when none is configured. */
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_secret'))) {
const clash_secret = generateClashSecret();
if (!isEmpty(clash_secret))
uci.set(uciconfig, ucimain, 'clash_api_secret', clash_secret);
}
normalizeLocalControllerOptions();
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_default_mode')))
uci.set(uciconfig, ucimain, 'clash_api_default_mode', 'Rule');
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_origin'))) {
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubex.github.io');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubexd.pages.dev');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'http://d.metacubex.one');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://yacd.metacubex.one');
}
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_private_network')))
uci.set(uciconfig, ucimain, 'clash_api_allow_private_network', '1');
if (isEmpty(uci.get(uciconfig, ucimain, 'metacubexd_url')))
uci.set(uciconfig, ucimain, 'metacubexd_url', 'https://metacubexd.pages.dev/#/overview');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
@@ -8,15 +8,17 @@
'use strict';
import { md5 } from 'digest';
import { open } from 'fs';
import { open, writefile } from 'fs';
import { connect } from 'ubus';
import { cursor } from 'uci';
import { urldecode, urlencode } from 'luci.http';
import { init_action } from 'luci.sys';
import { remove_subscription_nodes } from '/etc/homeproxy/scripts/node_references.uc';
import { parse_surge_subscription } from '/etc/homeproxy/scripts/subscription_parsers.uc';
import {
wGET, decodeBase64Str, getTime, isEmpty, parseURL,
wGETResult, decodeBase64Str, getTime, isEmpty, parseURL,
validation, HP_DIR, RUN_DIR
} from 'homeproxy';
@@ -31,6 +33,7 @@ const ucimain = 'config',
ucisubscription = 'subscription';
const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0',
allow_unsupported_tls_pin_fallback = uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback') || '0',
filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled',
filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [],
packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp',
@@ -72,6 +75,10 @@ const ubus = connect();
const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {};
/* Common var end */
const SUBSCRIPTION_DIAGNOSTICS_PATH = RUN_DIR + '/subscription-diagnostics.json';
const SUBSCRIPTION_UPDATE_STATUS_PATH = RUN_DIR + '/subscription-update-status.json';
let subscription_diagnostics = [];
/* Log */
system(`mkdir -p ${RUN_DIR}`);
function log(...args) {
@@ -80,6 +87,135 @@ function log(...args) {
logfile.close();
}
function reportSubscriptionDiagnostic(type, message, suggestion) {
push(subscription_diagnostics, {
type,
source: 'subscription',
message,
suggestion
});
}
function writeSubscriptionDiagnostics() {
system(`mkdir -p ${RUN_DIR}`);
if (length(subscription_diagnostics)) {
writefile(SUBSCRIPTION_DIAGNOSTICS_PATH, sprintf('%.J\n', {
time: time(),
items: subscription_diagnostics
}));
} else {
system(`rm -f ${SUBSCRIPTION_DIAGNOSTICS_PATH}`);
}
}
function writeSubscriptionUpdateStatus(running, completed, update_result, error) {
system(`mkdir -p ${RUN_DIR}`);
writefile(SUBSCRIPTION_UPDATE_STATUS_PATH, sprintf('%.J\n', {
time: time(),
running: !!running,
completed: !!completed,
update_result,
error: error || null
}));
}
function sanitizeCommandError(text) {
text = trim(text || '');
if (isEmpty(text))
return null;
text = split(text, '\n')[0] || text;
return text;
}
function uci_value_equal(a, b) {
if (type(a) == 'array')
a = map(a, (v) => sprintf('%s', v));
else
a = sprintf('%s', a);
if (type(b) == 'array')
b = map(b, (v) => sprintf('%s', v));
else
b = sprintf('%s', b);
return sprintf('%J', a) == sprintf('%J', b);
}
function setOrDeleteList(section, option, values) {
if (length(values))
uci.set(uciconfig, section, option, values);
else
uci.delete(uciconfig, section, option);
}
function logCleanupChange(change) {
if (!change || !change.node_id)
return;
let target = sprintf('%s.%s', change.section || '-', change.option || '-'),
action = change.action || 'update',
value = (action === 'set') ? sprintf(' to %s', change.value) : '';
log(sprintf('Cleaned reference to deleted subscription node %s: %s %s%s.',
change.node_id, target, action, value));
}
function version_at_least(version, major, minor, patch) {
const m = match(version || '', /^([0-9]+)\.([0-9]+)\.([0-9]+)/);
if (!m)
return false;
const current = [ int(m[1]), int(m[2]), int(m[3]) ],
required = [ major, minor, patch ];
for (let i = 0; i < 3; i++) {
if (current[i] > required[i])
return true;
if (current[i] < required[i])
return false;
}
return true;
}
function tls_cert_pin_unsupported() {
return !version_at_least(sing_features.version, 1, 13, 0);
}
function apply_tls_cert_pin_policy(config, label, pin) {
if (!pin || !tls_cert_pin_unsupported())
return;
const node_label = label || config.label || 'NULL',
version = sing_features.version || 'unknown';
if (config.tls_insecure === '1')
return;
if (allow_unsupported_tls_pin_fallback === '1' || config.type === 'hysteria2') {
config.tls_insecure = '1';
log(sprintf('Node %s uses server certificate fingerprint, but sing-box %s cannot express it; enabling explicit TLS insecure compatibility fallback.',
node_label,
version));
reportSubscriptionDiagnostic('warning',
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256;已降级为 TLS insecure 继续兼容。', node_label, version),
(config.type === 'hysteria2')
? '这是延续旧行为的兼容回退;如需更严格校验,请升级到支持 certificate_public_key_sha256 的 sing-box'
: '若安全要求较高,请关闭“Allow unsupported certificate pin fallback”,让此类节点默认跳过');
return;
}
config.__skip_reason = sprintf('该节点使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', version);
reportSubscriptionDiagnostic('warning',
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', node_label, version),
'升级到支持 certificate_public_key_sha256 的 sing-box,或显式开启兼容 fallback 后重新更新订阅');
log(sprintf('Skipping node %s: server certificate fingerprint is unsupported by sing-box %s.',
node_label,
version));
}
function parse_uri(uri) {
let config, url, params;
@@ -190,6 +326,7 @@ function parse_uri(uri) {
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
apply_tls_cert_pin_policy(config, config.label, params.pinSHA256);
break;
case 'socks':
@@ -474,23 +611,26 @@ function parse_uri(uri) {
}
function main() {
if (via_proxy !== '1') {
log('Stopping service...');
init_action('homeproxy', 'stop');
}
for (let url in subscription_urls) {
url = replace(url, /#.*$/, '');
const groupHash = md5(url);
node_cache[groupHash] = {};
const res = wGET(url, user_agent);
const fetch_result = wGETResult(url, user_agent) || {},
res = fetch_result.stdout;
if (isEmpty(res)) {
const reason = sanitizeCommandError(fetch_result.stderr);
log(sprintf('Failed to fetch resources from %s.', url));
if (!isEmpty(reason))
log(sprintf('Fetch stderr: %s', reason));
reportSubscriptionDiagnostic('error',
sprintf('订阅地址拉取失败:%s', url),
reason || '请检查路由器到订阅源的连通性、DNS 解析和 TLS 访问是否正常');
continue;
}
let nodes;
let nodes, parsed_as_surge = false;
try {
nodes = json(res).servers || json(res);
@@ -498,17 +638,41 @@ function main() {
if (nodes[0].server && nodes[0].method)
map(nodes, (_, i) => nodes[i].nodetype = 'sip008');
} catch(e) {
nodes = decodeBase64Str(res);
nodes = nodes ? split(trim(replace(nodes, / /g, '_')), '\n') : [];
/* 先识别 Surge/Clash 托管配置格式(YAML 风格),再回退 Base64。 */
if (match(res, /(^|\n)[ \t]*proxies\s*:\s*(#[^\n]*)?(\n|$)/)) {
nodes = parse_surge_subscription({
isEmpty,
validation,
sing_features,
log,
apply_tls_cert_pin_policy
}, res);
parsed_as_surge = true;
} else {
nodes = decodeBase64Str(res);
nodes = nodes ? split(trim(replace(nodes, / /g, '_')), '\n') : [];
}
}
let count = 0;
for (let node in nodes) {
let config;
if (!isEmpty(node))
config = parse_uri(node);
if (!isEmpty(node)) {
/* 信任边界来自控制流,而不是节点内容:只有 parse_surge_subscription()
* 分支产出的节点才会被当作已解析 config。任意 JSON 中伪造的
* 'nodetype'/'type' 不会绕过 parse_uri(),因为 JSON 路径下
* parsed_as_surge=false,仍然走原有校验和白名单映射。 */
if (parsed_as_surge && type(node) == 'object')
config = node;
else
config = parse_uri(node);
}
if (isEmpty(config))
continue;
if (!isEmpty(config.__skip_reason)) {
log(sprintf('Skipping node %s: %s', config.label || 'NULL', config.__skip_reason));
continue;
}
const label = config.label;
config.label = null;
@@ -543,17 +707,20 @@ function main() {
}
if (isEmpty(node_result)) {
log('Failed to update subscriptions: no valid node found.');
let fetch_errors = filter(subscription_diagnostics, (item) => item?.type === 'error' && item?.source === 'subscription');
if (via_proxy !== '1') {
log('Starting service...');
init_action('homeproxy', 'start');
}
log('Failed to update subscriptions: no valid node found.');
if (!length(fetch_errors))
reportSubscriptionDiagnostic('error',
'订阅更新失败:没有找到可用节点。',
'请检查订阅地址、过滤规则,以及是否有节点因当前 sing-box 不支持证书指纹而被跳过');
return false;
}
let added = 0, removed = 0;
let added = 0, updated = 0, removed = 0,
stale_nodes = [],
stale_labels = {};
uci.foreach(uciconfig, ucinode, (cfg) => {
/* Nodes created by the user */
if (!cfg.grouphash)
@@ -564,20 +731,59 @@ function main() {
return null;
if (!node_cache[cfg.grouphash] || !node_cache[cfg.grouphash][cfg['.name']]) {
uci.delete(uciconfig, cfg['.name']);
removed++;
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
push(stale_nodes, cfg['.name']);
stale_labels[cfg['.name']] = cfg.label || cfg['.name'];
return null;
} else {
const node = node_cache[cfg.grouphash][cfg['.name']];
let node_changed = false;
for (let v in node) {
if (isEmpty(node[v]))
continue;
if (!(v in cfg) || !uci_value_equal(cfg[v], node[v]))
node_changed = true;
uci.set(uciconfig, cfg['.name'], v, node[v]);
}
map(keys(cfg), (v) => {
if (v in node_cache[cfg.grouphash][cfg['.name']])
uci.set(uciconfig, cfg['.name'], v, node_cache[cfg.grouphash][cfg['.name']][v]);
else
if (substr(v, 0, 1) == '.')
return null;
if (!(v in node) || isEmpty(node[v])) {
uci.delete(uciconfig, cfg['.name'], v);
node_changed = true;
}
});
node_cache[cfg.grouphash][cfg['.name']].isExisting = true;
if (node_changed)
updated++;
node.isExisting = true;
}
});
if (length(stale_nodes)) {
const cleanup = remove_subscription_nodes(uci, uciconfig, stale_nodes);
removed += cleanup.removed;
for (let node_id in stale_nodes)
log(sprintf('Removing node: %s.', stale_labels[node_id] || node_id));
if (cleanup.changed) {
if (uci.get(uciconfig, ucimain, 'main_node') !== main_node)
log(sprintf('Deleted subscription node affected main node, falling back to %s.',
uci.get(uciconfig, ucimain, 'main_node') || 'nil'));
if (uci.get(uciconfig, ucimain, 'main_udp_node') !== main_udp_node)
log(sprintf('Deleted subscription node affected main UDP node, falling back to %s.',
uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'));
for (let change in cleanup.changes)
logCleanupChange(change);
}
}
for (let nodes in node_result)
map(nodes, (node) => {
if (node.isExisting)
@@ -589,50 +795,54 @@ function main() {
added++;
log(sprintf('Adding node: %s.', node.label));
});
});
uci.commit(uciconfig);
let need_restart = (via_proxy !== '1');
let need_restart = (via_proxy !== '1') || added > 0 || updated > 0 || removed > 0;
if (!isEmpty(main_node)) {
const first_server = uci.get_first(uciconfig, ucinode);
if (first_server) {
const has_server = !!uci.get_first(uciconfig, ucinode);
if (has_server) {
let main_urltest_nodes;
if (main_node === 'urltest') {
main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes'), (v) => {
main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
setOrDeleteList(ucimain, 'main_urltest_nodes', main_urltest_nodes);
uci.commit(uciconfig);
}
if ((main_node === 'urltest') ? !length(main_urltest_nodes) : !uci.get(uciconfig, main_node)) {
uci.set(uciconfig, ucimain, 'main_node', first_server);
uci.set(uciconfig, ucimain, 'main_node', 'nil');
uci.commit(uciconfig);
need_restart = true;
log('Main node is gone, switching to the first node.');
log('Main node is gone, disabling main node.');
}
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
let main_udp_urltest_nodes;
if (main_udp_node === 'urltest') {
main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes'), (v) => {
main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [], (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
setOrDeleteList(ucimain, 'main_udp_urltest_nodes', main_udp_urltest_nodes);
uci.commit(uciconfig);
}
if ((main_udp_node === 'urltest') ? !length(main_udp_urltest_nodes) : !uci.get(uciconfig, main_udp_node)) {
uci.set(uciconfig, ucimain, 'main_udp_node', first_server);
uci.set(uciconfig, ucimain, 'main_udp_node', 'same');
uci.commit(uciconfig);
need_restart = true;
log('Main UDP node is gone, switching to the first node.');
log('Main UDP node is gone, falling back to the main node.');
}
}
} else {
@@ -651,19 +861,37 @@ function main() {
init_action('homeproxy', 'start');
}
log(sprintf('%s nodes added, %s removed.', added, removed));
log(sprintf('%s nodes added, %s updated, %s removed.', added, updated, removed));
log('Successfully updated subscriptions.');
return true;
}
if (!isEmpty(subscription_urls))
if (!isEmpty(subscription_urls)) {
writeSubscriptionUpdateStatus(true, false, null, null);
try {
call(main);
const ok = call(main);
writeSubscriptionUpdateStatus(false, true, ok === false ? false : true,
(ok === false) ? (subscription_diagnostics[0]?.message || '订阅更新失败。') : null);
} catch(e) {
const status_error = sprintf('订阅更新异常:%s: %s', e.type || 'error', e.message || e);
log('[FATAL ERROR] An error occurred during updating subscriptions:');
log(sprintf('%s: %s', e.type, e.message));
log(e.stacktrace[0].context);
reportSubscriptionDiagnostic('error',
status_error,
'请查看 HomeProxy 日志中的 [SUBSCRIBE] 记录并修复订阅配置');
log('Restarting service...');
init_action('homeproxy', 'stop');
init_action('homeproxy', 'start');
writeSubscriptionUpdateStatus(false, true, false, status_error);
}
} else {
subscription_diagnostics = [];
writeSubscriptionUpdateStatus(false, true, false, '未配置订阅地址。');
}
writeSubscriptionDiagnostics();
+50 -15
View File
@@ -27,6 +27,24 @@ log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
}
delete_ip_rules() {
local mark="$1"
local table="$2"
while ip rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
:
done
}
delete_ip6_rules() {
local mark="$1"
local table="$2"
while ip -6 rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
:
done
}
start_service() {
config_load "$CONF"
@@ -34,12 +52,10 @@ start_service() {
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
local outbound_node
if [ "$routing_mode" != "custom" ]; then
config_get outbound_node "config" "main_node" "nil"
else
config_get outbound_node "routing" "default_outbound" "nil"
fi
local outbound_node default_outbound
config_get outbound_node "config" "main_node" "nil"
config_get default_outbound "routing" "default_outbound" "nil"
[ "$outbound_node" != "nil" ] || outbound_node="$default_outbound"
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
@@ -111,18 +127,24 @@ start_service() {
config_get table_mark "infra" "table_mark" "100"
case "$proxy_mode" in
"redirect_tproxy")
local outbound_udp_node
local outbound_udp_node main_node_for_routing default_outbound_for_routing use_default_outbound_routing
config_get outbound_udp_node "config" "main_udp_node" "nil"
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
config_get main_node_for_routing "config" "main_node" "nil"
config_get default_outbound_for_routing "routing" "default_outbound" "nil"
[ "$routing_mode" != "custom" ] && [ "$main_node_for_routing" = "nil" ] && [ "$default_outbound_for_routing" != "nil" ] \
&& use_default_outbound_routing="1" || use_default_outbound_routing="0"
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ] || [ "$use_default_outbound_routing" = "1" ]; then
local tproxy_mark
config_get tproxy_mark "infra" "tproxy_mark" "101"
delete_ip_rules "$tproxy_mark" "$table_mark"
ip rule add fwmark "$tproxy_mark" table "$table_mark"
ip route add local 0.0.0.0/0 dev lo table "$table_mark"
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
if [ "$ipv6_support" -eq "1" ]; then
delete_ip6_rules "$tproxy_mark" "$table_mark"
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark"
ip -6 route add local ::/0 dev lo table "$table_mark"
ip -6 route replace local ::/0 dev lo table "$table_mark"
fi
fi
;;
@@ -131,14 +153,16 @@ start_service() {
config_get tun_name "infra" "tun_name" "singtun0"
config_get tun_mark "infra" "tun_mark" "102"
ip tuntap add mode tun user root name "$tun_name"
ip link show "$tun_name" >"/dev/null" 2>&1 || ip tuntap add mode tun user root name "$tun_name"
sleep 1s
ip link set "$tun_name" up
ip route replace default dev "$tun_name" table "$table_mark"
delete_ip_rules "$tun_mark" "$table_mark"
ip rule add fwmark "$tun_mark" lookup "$table_mark"
ip -6 route replace default dev "$tun_name" table "$table_mark"
delete_ip6_rules "$tun_mark" "$table_mark"
ip -6 rule add fwmark "$tun_mark" lookup "$table_mark"
;;
esac
@@ -173,6 +197,17 @@ start_service() {
procd_set_param respawn
procd_close_instance
# Clash API display proxy for dashboards.
local clash_api_enabled
config_get_bool clash_api_enabled "config" "clash_api_enabled" "0"
if [ "$clash_api_enabled" = "1" ]; then
procd_open_instance "clash-api-proxy"
procd_set_param command "/usr/bin/ucode" "$HP_DIR/scripts/clash_api_proxy.uc"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
fi
fi
if [ "$server_enabled" = "1" ]; then
@@ -267,17 +302,17 @@ stop_service() {
config_get tun_name "infra" "tun_name" "singtun0"
# Tproxy
ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
delete_ip_rules "$tproxy_mark" "$table_mark"
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
delete_ip6_rules "$tproxy_mark" "$table_mark"
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
# TUN
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
delete_ip_rules "$tun_mark" "$table_mark"
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
delete_ip6_rules "$tun_mark" "$table_mark"
# Nftables rules
for i in "homeproxy_dstnat_redir" "homeproxy_output_redir" \
@@ -34,6 +34,14 @@
"path": "homeproxy/server"
}
},
"admin/services/homeproxy/backup": {
"title": "Backup / Restore",
"order": 25,
"action": {
"type": "view",
"path": "homeproxy/backup"
}
},
"admin/services/homeproxy/status": {
"title": "Service Status",
"order": 30,
@@ -3,20 +3,58 @@
"description": "Grant access to homeproxy configuration",
"read": {
"file": {
"/etc/homeproxy/scripts/update_subscriptions.uc": [ "exec" ],
"/var/run/homeproxy/homeproxy.log": [ "read" ],
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
},
"ubus": {
"service": [ "list" ],
"luci.homeproxy": [ "*" ]
"luci.homeproxy": [
"acllist_read",
"connection_check",
"resources_get_version",
"singbox_get_features"
],
"luci.homeproxy_node_tools": [
"config_diagnostics",
"node_references",
"preview_node_filter",
"update_subscriptions_status",
"validate_regex"
]
},
"uci": [ "homeproxy" ]
},
"write": {
"file": {
"/tmp/homeproxy_certificate.tmp": [ "write" ]
"/tmp/homeproxy_certificate.tmp": [ "write" ],
"/tmp/homeproxy-backup-*.tar.gz": [ "read" ],
"/tmp/homeproxy-restore-*.tar.gz": [ "write" ]
},
"ubus": {
"luci.homeproxy": [
"acllist_read",
"acllist_write",
"certificate_write",
"log_clean",
"resources_update",
"singbox_generator"
],
"luci.homeproxy_backup": [
"backup_cleanup",
"backup_create",
"backup_get_upload_path",
"backup_restore",
"backup_validate"
],
"luci.homeproxy_node_tools": [
"remove_subscription_nodes",
"update_subscriptions"
],
"luci.homeproxy_tcping": [
"node_tcping",
"nodes_tcping"
]
},
"uci": [ "homeproxy" ]
}
@@ -163,7 +163,7 @@ const methods = {
const type = req.args?.type;
let result = {};
const fd = popen(`/usr/bin/sing-box generate ${type} ${shellquote(req.args?.params || '')}`);
const fd = popen('/usr/bin/sing-box generate ' + type + ' ' + shellquote(req.args?.params || ''));
if (fd) {
let ech_cfg_set = false;
let ech_key_set = false;
@@ -234,8 +234,13 @@ const methods = {
resources_get_version: {
args: { type: 'type' },
call: function(req) {
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
return { version: version, error: error() };
const allowed_types = [ 'china_ip4', 'china_ip6', 'china_list', 'gfw_list' ];
if (index(allowed_types, req.args?.type) === -1)
return { version: null, error: 'invalid resource type' };
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`) || '');
return { version, error: error() };
}
},
resources_update: {
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more