⛄ 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

@@ -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;
@@ -0,0 +1,105 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 面板入口 helper。
*/
'use strict';
'require baseclass';
const DEFAULT_DASHBOARD_URL = 'https://metacubexd.pages.dev/#/overview';
const DEFAULT_CONTROLLER = '127.0.0.1:9090';
const DEFAULT_PROXY_LISTEN = '127.0.0.1:9091';
return baseclass.extend({
parseController(value) {
let result = {
hostname: '',
port: '',
https: false
};
if (!value)
return result;
try {
let url = new URL(value.match(/^https?:\/\//) ? value : 'http://' + value);
result.hostname = url.hostname;
result.port = url.port;
result.https = url.protocol === 'https:';
} catch(e) { }
return result;
},
deriveProxyController(value) {
let controller = this.parseController(value || DEFAULT_CONTROLLER);
if (!controller.hostname)
return DEFAULT_PROXY_LISTEN;
let hostname = controller.hostname.replace(/^\[(.*)\]$/, '$1');
return String.format('%s:9091', hostname.includes(':') ? '[' + hostname + ']' : hostname);
},
configuredUrl(uci, config) {
return uci.get(config, 'config', 'metacubexd_url') || DEFAULT_DASHBOARD_URL;
},
baseUrl(uci, config) {
let dashboard = this.configuredUrl(uci, config),
hash = dashboard.indexOf('#');
if (hash >= 0)
dashboard = dashboard.slice(0, hash);
return dashboard.replace(/\/+$/, '');
},
setupUrl(uci, config) {
let enabled = uci.get(config, 'config', 'clash_api_enabled') === '1',
target = uci.get(config, 'config', 'clash_api_external_controller') || DEFAULT_CONTROLLER,
proxy = uci.get(config, 'config', 'clash_api_proxy_external_controller') || this.deriveProxyController(target),
controller = this.parseController(proxy),
secret = uci.get(config, 'config', 'clash_api_secret') || '';
if (!enabled || !controller.hostname)
return null;
let dashboard = this.configuredUrl(uci, config),
base = this.baseUrl(uci, config);
if (dashboard.includes('yacd.metacubex.one'))
return dashboard;
let params = new URLSearchParams();
params.set('hostname', controller.hostname);
if (controller.port)
params.set('port', controller.port);
params.set(controller.https ? 'https' : 'http', '1');
if (secret)
params.set('secret', secret);
return base + '/#/setup?' + params.toString();
},
renderButton(uci, config) {
let url = this.setupUrl(uci, config);
if (!url)
return null;
return E('span', { 'class': 'homeproxy-dashboard-action' }, [
E('a', {
'class': 'btn cbi-button cbi-button-action',
'href': url,
'target': '_blank',
'rel': 'noreferrer noopener'
}, [ _('MetaCubeXD Dashboard') ]),
E('span', {
'style': 'display:block;margin-top:.35em;color:var(--text-color-medium,#666);font-size:90%;'
}, _('When using the remote MetaCubeXD dashboard, the Clash API secret is provided to that web page. Use a self-hosted or local dashboard when stronger security is required.'))
]);
}
});
@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 配置诊断 helper。
*/
'use strict';
'require baseclass';
'require rpc';
'require homeproxy as hp';
const callConfigDiagnostics = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'config_diagnostics',
expect: { '': {} }
});
return baseclass.extend({
load() {
return L.resolveDefault(callConfigDiagnostics(), { result: true, items: [] });
},
show(diagnostics) {
return hp.showConfigDiagnostics(diagnostics);
}
});
@@ -0,0 +1,90 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 节点正则预览 helper。
*/
'use strict';
'require baseclass';
'require dom';
'require homeproxy as hp';
'require rpc';
const callValidateRegex = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'validate_regex',
params: ['pattern'],
expect: { '': {} }
});
const callPreviewNodeFilter = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'preview_node_filter',
params: ['manual_nodes', 'node_filter', 'node_filter_exclude', 'node'],
expect: { '': {} }
});
return baseclass.extend({
normalizeNodeList(value) {
let nodes = [];
for (let node_id of hp.toArray(value))
if (node_id && !nodes.includes(node_id))
nodes.push(node_id);
return nodes;
},
validate(pattern) {
return L.resolveDefault(callValidateRegex(pattern), { result: false, error: _('Unknown error.') });
},
preview(manual_nodes, node_filter, node_filter_exclude, node) {
return L.resolveDefault(callPreviewNodeFilter(
manual_nodes,
node_filter,
node_filter_exclude,
node
), { result: false, error: _('Unknown error.') });
},
setPreviewVisible(container, visible) {
let row = container.closest('.cbi-value');
if (row)
row.style.display = visible ? '' : 'none';
},
renderPreview(container, res, nodeName) {
if (!res.result) {
let message = _('Expecting: %s').format(_('valid regular expression'));
if (res.error)
message += ': ' + res.error;
dom.content(container, E('em', { 'style': 'color: #a33' }, message));
return;
}
let nodes = this.normalizeNodeList(res.nodes);
if (!nodes.length) {
dom.content(container, E('em', {}, _('No effective nodes.')));
return;
}
let content = [];
if (res.truncated)
content.push(E('div', { 'style': 'color: #a66; margin-bottom: .35em;' },
_('Only the first %d effective nodes are shown. Narrow the regex to avoid generating an oversized node group.').format(res.max_result_nodes || nodes.length)));
if (res.scan_truncated)
content.push(E('div', { 'style': 'color: #a66; margin-bottom: .35em;' },
_('Regex matching stopped after scanning %d nodes. Narrow the regex or reduce subscription size.').format(res.max_scan_nodes || 0)));
content.push(E('ul', {
'style': 'margin: 0; padding-left: 1.4em; max-height: 28em; overflow: auto;'
}, nodes.map((node_id) => E('li', {
'title': node_id
}, nodeName(node_id)))));
dom.content(container, content);
}
});
@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 路由节点前端 helper。
*/
'use strict';
'require baseclass';
'require uci';
return baseclass.extend({
routingNodeName(res) {
let type = _('Routing node');
if (res.node === 'urltest')
type = _('URLTest');
else if (res.node === 'selector')
type = _('Selector');
return String.format('[%s] %s', type, res.label || res['.name']);
},
buildRoutingNodeNames(uci_config) {
let names = {};
uci.sections(uci_config, 'routing_node', (res) => {
names[res['.name']] = this.routingNodeName(res);
});
return names;
},
addSelectableOutbounds(option, uci_config, proxy_nodes, section_id, include_routing_nodes) {
for (let i in proxy_nodes)
option.value(i, proxy_nodes[i]);
if (!include_routing_nodes)
return;
uci.sections(uci_config, 'routing_node', (res) => {
if (res.enabled === '1' && res['.name'] !== section_id)
option.value(res['.name'], this.routingNodeName(res));
});
},
nodeDisplayName(node_id, proxy_nodes, routing_node_names) {
return proxy_nodes[node_id] || routing_node_names[node_id] || node_id;
},
toArray(value) {
if (!value)
return [];
return Array.isArray(value) ? value : [ value ];
},
routingNodeEdges(res) {
let edges = [];
if (res.node === 'selector') {
edges = edges.concat(this.toArray(res.selector_nodes));
edges = edges.concat(this.toArray(res.selector_default));
} else if (res.node === 'urltest') {
edges = edges.concat(this.toArray(res.urltest_nodes));
} else {
edges = edges.concat(this.toArray(res.node));
}
return edges.concat(this.toArray(res.outbound));
},
selectorHasPath(uci_config, start, target, path_cache, seen) {
if (!start || !target)
return false;
if (start === target)
return true;
let key = start + '->' + target;
if (path_cache[key] !== undefined)
return path_cache[key];
if (seen[start])
return false;
seen[start] = true;
let found = false;
uci.sections(uci_config, 'routing_node', (res) => {
if (found || res['.name'] !== start)
return;
if (res.enabled !== '1' || !res.node)
return;
for (let node_id of this.routingNodeEdges(res)) {
if (node_id === target || this.selectorHasPath(uci_config, node_id, target, path_cache, seen)) {
found = true;
return;
}
}
});
path_cache[key] = found;
return found;
}
});
@@ -0,0 +1,339 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 节点测速前端 helper。
*/
'use strict';
'require baseclass';
'require rpc';
'require ui';
const MAX_NODES = 300;
const HELP_TEXT = _('Speed tests use the current runtime configuration. To test all nodes, add them to a running node group with a node regex such as .*');
const running = {};
const callNodesTcping = rpc.declare({
object: 'luci.homeproxy_tcping',
method: 'nodes_tcping',
params: [ 'sections' ],
expect: { '': {} }
});
const callNodeTcping = rpc.declare({
object: 'luci.homeproxy_tcping',
method: 'node_tcping',
params: [ 'section' ],
expect: { '': {} }
});
function replaceElementContent(el, content) {
if (!el)
return;
while (el.firstChild)
el.removeChild(el.firstChild);
if (Array.isArray(content)) {
for (let node of content)
el.appendChild((typeof node === 'string') ? document.createTextNode(node) : node);
} else {
el.appendChild((typeof content === 'string') ? document.createTextNode(content) : content);
}
}
function svgElement(tag, attrs, children) {
let el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (let key in (attrs || {}))
el.setAttribute(key, attrs[key]);
for (let child of (children || []))
el.appendChild(child);
return el;
}
function renderSpeedtestIcon(extra_class) {
return svgElement('svg', {
'xmlns': 'http://www.w3.org/2000/svg',
'width': '18',
'height': '18',
'viewBox': '0 0 24 24',
'fill': 'none',
'stroke': 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'class': extra_class || ''
}, [
svgElement('path', { 'd': 'M5.636 19.364a9 9 0 1 1 12.728 0' }),
svgElement('path', { 'd': 'M16 9l-4 4' })
]);
}
function renderSpinner() {
return E('span', { 'class': 'homeproxy-tcping-spinner' });
}
function setButtonIcon(btn, busy) {
if (!btn)
return;
replaceElementContent(btn, renderSpeedtestIcon(busy ? 'homeproxy-tcping-icon homeproxy-tcping-icon-busy' : 'homeproxy-tcping-icon'));
}
function nodeId(section_id) {
return 'homeproxy-node-tcping-' + String(section_id).replace(/[^A-Za-z0-9_-]/g, (c) =>
'_' + c.charCodeAt(0).toString(16) + '_');
}
function ensureStyle() {
if (document.getElementById('homeproxy-tcping-style'))
return;
document.head.appendChild(E('style', { 'id': 'homeproxy-tcping-style' }, `
@keyframes homeproxy-tcping-spin { to { transform: rotate(360deg); } }
@keyframes homeproxy-tcping-pulse { 50% { opacity: .45; } }
.homeproxy-tcping-button {
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.homeproxy-tcping-icon {
width: 18px;
height: 18px;
}
.homeproxy-tcping-icon-busy {
animation: homeproxy-tcping-pulse 1s ease-in-out infinite;
}
.homeproxy-latency-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.8em;
min-height: 1.7em;
padding: .16em .55em;
border-radius: 6px;
cursor: pointer;
font-variant-numeric: tabular-nums;
line-height: 1.3;
user-select: none;
transition: background-color .15s ease, transform .15s ease;
}
.homeproxy-latency-pill:hover {
background-color: rgba(128, 128, 128, .14);
transform: scale(1.04);
}
.homeproxy-latency-pill:active {
transform: scale(.96);
}
.homeproxy-latency-pill.homeproxy-tcping-busy {
color: gray !important;
}
.homeproxy-tcping-spinner {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 999px;
animation: homeproxy-tcping-spin .8s linear infinite;
}
`));
}
function formatTestedAt(result) {
let tested_at = result?.tested_at;
if (!tested_at)
return '';
try {
return _('Tested at: %s').format(new Date(tested_at * 1000).toLocaleString());
} catch (e) {
return '';
}
}
function setResult(section_id, text, color, title) {
let el = document.getElementById(nodeId(section_id));
if (!el)
return false;
replaceElementContent(el, text);
el.title = title || '';
el.style.setProperty('color', color || '');
el.classList.remove('homeproxy-tcping-busy');
el.setAttribute('aria-busy', 'false');
return true;
}
function setBusy(section_id) {
let el = document.getElementById(nodeId(section_id));
if (!el)
return false;
replaceElementContent(el, renderSpinner());
el.title = _('Testing...');
el.classList.add('homeproxy-tcping-busy');
el.setAttribute('aria-busy', 'true');
return true;
}
function renderResult(section_id, result) {
let target = result?.target || '',
error = result?.error || '',
tested_at = formatTestedAt(result),
title = [ target, error, tested_at ].filter((v) => v).join('\n');
switch (result?.status) {
case 'ok':
setResult(section_id, '%d ms'.format(result.delay), 'green', title);
break;
case 'timeout':
setResult(section_id, _('Timeout'), 'red', title);
break;
case 'unsupported':
setResult(section_id, _('Unsupported'), 'gray', title);
break;
case 'invalid':
setResult(section_id, _('No address'), 'red', title);
break;
case 'missing':
setResult(section_id, _('Missing'), 'red', title);
break;
case 'skipped':
setResult(section_id, _('Skipped'), 'gray', title);
break;
case 'unloaded':
setResult(section_id, _('Not loaded'), 'gray', title);
break;
default:
setResult(section_id, _('Failed'), 'red', title);
break;
}
return true;
}
function runSingle(section_id, notices) {
return L.resolveDefault(callNodeTcping(section_id), {}).then((res) => {
if (!res.result) {
let message = res.error || _('Connectivity test failed.');
renderResult(section_id, { status: 'failed', error: message });
notices[message] = true;
return;
}
if (res.warning)
notices[res.warning] = true;
renderResult(section_id, res.node);
}).catch((err) => {
let message = err.message || String(err);
renderResult(section_id, { status: 'failed', error: message });
notices[message] = true;
});
}
return baseclass.extend({
helpText: HELP_TEXT,
nodeId: nodeId,
ensureStyle: ensureStyle,
renderIcon: renderSpeedtestIcon,
runNode(section_id, ev) {
let notices = {};
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
if (running[section_id])
return Promise.resolve();
running[section_id] = true;
setBusy(section_id);
return runSingle(section_id, notices).then(() => {
for (let message in notices)
ui.addNotification(null, E('p', message));
}).finally(() => {
running[section_id] = false;
});
},
runNodes(section_ids, ev) {
let btn = ev?.currentTarget || ev?.target,
test_sections = section_ids.slice(0, MAX_NODES),
skipped_sections = section_ids.slice(MAX_NODES),
notices = {};
if (!section_ids.length) {
ui.addNotification(null, E('p', _('No testable nodes.')));
return Promise.resolve();
}
let visible = 0;
for (let section_id of test_sections) {
if (setBusy(section_id))
visible++;
}
for (let section_id of skipped_sections)
renderResult(section_id, {
status: 'skipped',
error: _('At most %d nodes can be tested at once.').format(MAX_NODES)
});
if (!visible)
ui.addNotification(null, E('p', _('No latency column was found on this page. Force-refresh the page and try again.')));
if (btn) {
btn.disabled = true;
setButtonIcon(btn, true);
}
return L.resolveDefault(callNodesTcping(test_sections), {}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Connectivity test failed.'));
if (res.warning)
notices[res.warning] = true;
let nodes = res.nodes || {};
for (let section_id of test_sections)
renderResult(section_id, nodes[section_id]);
}).catch((err) => {
let message = err.message || String(err);
for (let section_id of test_sections)
renderResult(section_id, { status: 'failed', error: message });
notices[message] = true;
}).then(() => {
for (let message in notices)
ui.addNotification(null, E('p', message));
}).then(() => {
if (skipped_sections.length) {
let message = _('Skipped %d nodes. At most %d nodes can be tested at once.').format(skipped_sections.length, MAX_NODES);
ui.addNotification(null, E('p', message));
}
if (btn) {
btn.disabled = false;
setButtonIcon(btn, false);
}
});
}
});
@@ -0,0 +1,221 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2026 HomeProxy Custom
*/
'use strict';
'require fs';
'require rpc';
'require ui';
'require view';
// 当前页面会话里的动态路径状态
let currentBackupPath = null;
let currentRestorePath = null;
const callBackupCreate = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_create',
expect: { '': {} }
});
const callBackupGetUploadPath = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_get_upload_path',
expect: { '': {} }
});
const callBackupCleanup = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_cleanup',
params: ['path'],
expect: { '': {} }
});
const callBackupValidate = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_validate',
params: ['path'],
expect: { '': {} }
});
const callBackupRestore = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_restore',
params: ['path'],
expect: { '': {} }
});
function downloadFile(path, filename) {
return fs.read_direct(path, 'blob').then((blob) => {
const url = window.URL.createObjectURL(blob);
let a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.setTimeout(() => {
a.remove();
window.URL.revokeObjectURL(url);
}, 100);
});
}
function renderFileList(files) {
if (!files || !files.length)
return E('em', _('No files.'));
return E('ul', {}, files.map((file) => E('li', {}, file)));
}
function setButtonText(btn, text) {
if (btn && btn.firstChild)
btn.firstChild.data = text;
}
return view.extend({
handleCreateBackup(ev) {
const btn = ev.currentTarget || ev.target;
setButtonText(btn, _('Generating backup file...'));
return L.resolveDefault(callBackupCreate(), {}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Failed to generate backup file.'));
// 记录后端生成的备份路径,供浏览器下载
currentBackupPath = res.download_path || res.path;
if (!currentBackupPath)
throw new Error(_('Backup file path was not returned.'));
const filename = 'homeproxy-backup-%s.tar.gz'.format((new Date()).toISOString().replace(/[:.]/g, '-'));
return downloadFile(currentBackupPath, filename).then(() => {
ui.addNotification(null, E('p', _('Backup file has been generated and download has started.')), 'info');
// 下载后清理后端临时备份文件,避免证书和私钥留在 /tmp
return L.resolveDefault(callBackupCleanup(currentBackupPath), {});
});
}).catch((err) => {
ui.addNotification(null, E('p', err.message || err));
}).finally(() => {
setButtonText(btn, _('Generate backup file'));
});
},
handleUploadRestore(ev) {
const btn = ev.currentTarget || ev.target;
setButtonText(btn, _('Uploading backup file...'));
// 先向后端申请本次上传路径
return L.resolveDefault(callBackupGetUploadPath(), {}).then((res) => {
if (!res.upload_path)
throw new Error(_('Failed to get upload path'));
currentRestorePath = res.upload_path;
return ui.uploadFile(currentRestorePath);
}).then(() => {
setButtonText(btn, _('Checking backup file...'));
return L.resolveDefault(callBackupValidate(currentRestorePath), {});
}).then((res) => {
if (!res.valid)
throw new Error(res.error || _('The uploaded backup file is invalid.'));
ui.showModal(_('Restore HomeProxy backup?'), [
E('p', _('The uploaded backup file contains the following HomeProxy source configuration. Restoring it will overwrite the current HomeProxy settings, custom direct/proxy lists, and uploaded certificates and private keys.')),
renderFileList(res.files),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callBackupCleanup(currentRestorePath), {}).finally(ui.hideModal);
})
}, [ _('Cancel') ]),
' ',
E('button', {
'class': 'btn cbi-button-action important',
'click': ui.createHandlerFn(this, 'handleRestoreConfirm')
}, [ _('Continue') ])
])
]);
}).catch((err) => {
ui.addNotification(null, E('p', err.message || err));
return L.resolveDefault(callBackupCleanup(currentRestorePath), {});
}).finally(() => {
setButtonText(btn, _('Upload backup file...'));
});
},
handleRestoreConfirm() {
ui.showModal(_('Restoring HomeProxy backup...'), [
E('p', { 'class': 'spinning' }, _('Applying backup file and restarting HomeProxy.'))
]);
return L.resolveDefault(callBackupRestore(currentRestorePath), {}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Restore failed.'));
ui.showModal(_('Restore complete'), [
E('p', _('HomeProxy configuration has been restored and the service has restarted.')),
E('p', _('The rollback archive for the previous HomeProxy configuration has been saved to %s.').format(res.rollback || '/tmp/homeproxy-rollback.tar.gz')),
renderFileList(res.files),
E('div', { 'class': 'right' },
E('button', { 'class': 'btn', 'click': ui.hideModal }, [ _('Close') ])
)
]);
}).catch((err) => {
ui.addNotification(null, E('p', err.message || err));
ui.hideModal();
});
},
render() {
const readonly = !L.hasViewPermission();
return E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('HomeProxy configuration backup / restore')),
E('div', { 'class': 'cbi-map-descr' },
_('Export and import HomeProxy source configuration to fully restore WebUI settings after reinstallation.')),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Backup')),
E('div', { 'class': 'cbi-section-descr' },
_('Download a tar.gz backup containing /etc/config/homeproxy, custom direct/proxy lists, and uploaded certificates and private keys. The backup contains plaintext private keys; keep it safe and avoid transmitting or storing it through untrusted channels. Restore only verifies file integrity (SHA-256 manifest), not source authenticity.')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Download backup')),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action important',
'disabled': readonly || null,
'click': ui.createHandlerFn(this, 'handleCreateBackup')
}, [ _('Generate backup file') ])
])
])
]),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Restore')),
E('div', { 'class': 'cbi-section-descr' },
_('Upload a HomeProxy backup file and overwrite the current HomeProxy source configuration. A rollback archive is saved automatically before restore.')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Restore backup')),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action important',
'disabled': readonly || null,
'click': ui.createHandlerFn(this, 'handleUploadRestore')
}, [ _('Upload backup file...') ])
])
])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -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 */