mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
⛄ Sync 2026-07-31 00:26:24
This commit is contained in:
1 parent
47d0743808
commit
b44a4364c4
125 files changed
+3954
-140418
No files matched your search
@@ -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 */
|
||||
|
||||
|
||||
Reference in new issue
Block a user