🎨 Sync 2026-09-15 11:19:33

This commit is contained in:
github-actions[bot] committed 2026-09-15 11:19:33 +08:00
1 parent a9e0557b71
commit ac76f4cbc9
106 files changed
+17125 -19121

No files matched your search

@@ -34,14 +34,6 @@
"path": "homeproxy/server"
}
},
"admin/services/homeproxy/backup": {
"title": "Backup / Restore",
"order": 25,
"action": {
"type": "view",
"path": "homeproxy/backup"
}
},
"admin/services/homeproxy/status": {
"title": "Service Status",
"order": 30,
@@ -3,58 +3,25 @@
"description": "Grant access to homeproxy configuration",
"read": {
"file": {
"/etc/homeproxy/scripts/update_subscriptions.uc": [ "exec" ],
"/var/run/homeproxy/homeproxy.log": [ "read" ],
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
},
"ubus": {
"rc": [ "*" ],
"service": [ "list" ],
"luci.homeproxy": [
"acllist_read",
"connection_check",
"resources_get_version",
"singbox_get_features"
],
"luci.homeproxy_node_tools": [
"config_diagnostics",
"node_references",
"preview_node_filter",
"update_subscriptions_status",
"validate_regex"
]
"luci.homeproxy": [ "*" ]
},
"uci": [ "homeproxy" ]
},
"write": {
"file": {
"/tmp/homeproxy_certificate.tmp": [ "write" ],
"/tmp/homeproxy-backup-*.tar.gz": [ "read" ],
"/tmp/homeproxy-restore-*.tar.gz": [ "write" ]
"/tmp/homeproxy_panel.tmp": [ "write" ]
},
"ubus": {
"luci.homeproxy": [
"acllist_read",
"acllist_write",
"certificate_write",
"log_clean",
"resources_update",
"singbox_generator"
],
"luci.homeproxy_backup": [
"backup_cleanup",
"backup_create",
"backup_get_upload_path",
"backup_restore",
"backup_validate"
],
"luci.homeproxy_node_tools": [
"remove_subscription_nodes",
"update_subscriptions"
],
"luci.homeproxy_tcping": [
"node_tcping",
"nodes_tcping"
]
"rc": [ "*" ]
},
"uci": [ "homeproxy" ]
}
File diff suppressed because it is too large. Load diff
@@ -1,553 +0,0 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 备份和恢复 RPC。
*/
'use strict';
import { lstat, popen, readfile, stat, writefile } from 'fs';
import { cursor } from 'uci';
import {
local_ipv4_addresses,
normalize_local_controller_option
} from '/etc/homeproxy/scripts/clash_api.uc';
function shellquote(s) {
return `'${replace(s, "'", "'\\''")}'`;
}
const HP_DIR = '/etc/homeproxy';
const RUN_DIR = '/var/run/homeproxy';
function commandOutput(command) {
const fd = popen(`${command} 2>&1`);
let out = '';
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line'))
out += line;
fd.close();
}
return out;
}
function generateTempSuffix() {
let suffix = trim(commandOutput("hexdump -n 8 -e '1/1 \"%02x\"' /dev/urandom 2>/dev/null"));
if (!match(suffix, /^[a-f0-9]{16}$/))
suffix = replace(trim(commandOutput('date +%s 2>/dev/null')), /[^a-f0-9]/g, '');
return suffix;
}
function createWorkDir() {
const work_dir = '/tmp/homeproxy-work-' + generateTempSuffix();
system(`mkdir -p ${shellquote(work_dir)} && chmod 700 ${shellquote(work_dir)}`);
return work_dir;
}
let BACKUP_ARCHIVE = '/tmp/homeproxy-backup-' + generateTempSuffix() + '.tar.gz';
function isEmpty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function normalizeRestoredLocalControllers() {
let local_addresses = local_ipv4_addresses(commandOutput);
let fallback_host = local_addresses[0];
if (isEmpty(fallback_host))
return {};
const hpuci = cursor();
let adjustments = {};
hpuci.load('homeproxy');
for (let item in [
[ 'clash_api_proxy_external_controller', 9091, false ]
]) {
let next = normalize_local_controller_option(hpuci, 'homeproxy', 'config',
local_addresses, fallback_host, item[0], item[1], item[2]);
if (next)
adjustments[item[0]] = next;
}
if (length(keys(adjustments)))
hpuci.commit('homeproxy');
return adjustments;
}
function restoredServiceExpectations() {
const hpuci = cursor();
hpuci.load('homeproxy');
const main_node = hpuci.get('homeproxy', 'config', 'main_node') || 'nil',
default_outbound = hpuci.get('homeproxy', 'routing', 'default_outbound') || 'nil',
outbound_node = isEmpty(main_node) ? default_outbound : main_node,
server_enabled = hpuci.get('homeproxy', 'server', 'enabled') || '0';
return {
client: !isEmpty(outbound_node),
server: server_enabled === '1'
};
}
function checkSingBoxRuntimeConfig(path, label) {
const config = stat(path);
if (!config || config.type !== 'file' || config.size <= 0)
return `${label}运行配置未生成`;
if (system(`/usr/bin/sing-box check --config ${shellquote(path)} >/dev/null 2>&1`) !== 0)
return `${label}运行配置校验失败`;
return null;
}
function validateRestoredService() {
const expected = restoredServiceExpectations();
if (expected.client) {
let error = checkSingBoxRuntimeConfig(`${RUN_DIR}/sing-box-c.json`, '客户端');
if (error)
return { result: false, error };
}
if (expected.server) {
let error = checkSingBoxRuntimeConfig(`${RUN_DIR}/sing-box-s.json`, '服务端');
if (error)
return { result: false, error };
}
if (expected.client || expected.server) {
const status = commandOutput('/etc/init.d/homeproxy status');
if (match(status, /active with no instances/))
return { result: false, error: 'HomeProxy 服务未启动任何实例' };
if (system('/etc/init.d/homeproxy status >/dev/null 2>&1') !== 0)
return { result: false, error: 'HomeProxy 服务状态异常' };
}
return { result: true };
}
function archiveMemberPath(line) {
line = trim(line || '');
if (!line)
return null;
let matchPath = match(line, /^[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]+(.+)$/);
let path = matchPath ? matchPath[1] : line;
path = replace(path, /^\.\//, '');
path = replace(path, /^\/+/, '');
return path;
}
function allowedBackupPath(path) {
if (!path || match(path, /(^|\/)\.\.(\/|$)/) || match(path, /^etc\/homeproxy\/certs\/.*\//))
return false;
return (
path === 'etc/config/homeproxy' ||
path === 'etc/homeproxy/resources/direct_list.txt' ||
path === 'etc/homeproxy/resources/proxy_list.txt' ||
match(path, /^etc\/homeproxy\/certs\/[^\/]+$/)
);
}
function validateBackupArchive(path) {
const filestat = lstat(path);
if (!filestat || filestat.type !== 'file' || filestat.size <= 0)
return { valid: false, error: '备份文件不存在或内容为空' };
const fd = popen(`/bin/tar -tvzf ${shellquote(path)} 2>&1`);
let files = [], errors = [];
if (!fd)
return { valid: false, error: '无法运行 tar 检查备份文件' };
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
let path = archiveMemberPath(line);
if (!path)
continue;
let mode = match(line, /^([^ \t]+)/);
if (mode && substr(mode[1], 0, 1) !== '-') {
errors = [ ...errors, `备份文件内包含不支持的条目类型: ${path}` ];
continue;
}
if (path === 'homeproxy-backup-manifest.json')
continue;
if (!allowedBackupPath(path))
errors = [ ...errors, `备份文件内包含不支持的路径: ${path}` ];
else
files = [ ...files, path ];
}
fd.close();
if (length(errors))
return { valid: false, error: join('\n', errors), files };
if (index(files, 'etc/config/homeproxy') === -1)
return { valid: false, error: '备份文件缺少 etc/config/homeproxy', files };
return { valid: true, files };
}
function createBackupArchive(path) {
let files = [];
if (stat('/etc/config/homeproxy')?.type === 'file')
files = [ ...files, 'etc/config/homeproxy' ];
if (stat(`${HP_DIR}/resources/direct_list.txt`)?.type === 'file')
files = [ ...files, 'etc/homeproxy/resources/direct_list.txt' ];
if (stat(`${HP_DIR}/resources/proxy_list.txt`)?.type === 'file')
files = [ ...files, 'etc/homeproxy/resources/proxy_list.txt' ];
const certs = commandOutput(`[ -d /etc/homeproxy/certs ] && find /etc/homeproxy/certs -maxdepth 1 -type f -print || true`);
for (let file in split(trim(certs), /\n/)) {
file = archiveMemberPath(file);
if (allowedBackupPath(file))
files = [ ...files, file ];
}
if (!length(files))
return { result: false, error: '没有可备份的 HomeProxy 文件' };
const work_dir = createWorkDir();
const staging_root = `${work_dir}/staging`;
system(`mkdir -p ${shellquote(staging_root)}`);
for (let file in files) {
let src = '/' + file;
let dst = `${staging_root}/${file}`;
let dst_dir = match(dst, /^(.*)\/[^\/]+$/);
if (dst_dir && dst_dir[1])
system(`mkdir -p ${shellquote(dst_dir[1])}`);
let exit_code = system(`cp ${shellquote(src)} ${shellquote(dst)}`);
if (exit_code !== 0) {
system(`rm -rf ${shellquote(work_dir)}`);
return { result: false, error: `复制文件失败: ${file}` };
}
}
let manifest = {};
for (let file in files) {
let fullpath = `${staging_root}/${file}`;
let filestat = stat(fullpath);
if (filestat?.type === 'file') {
let hash = trim(commandOutput(`sha256sum ${shellquote(fullpath)} 2>/dev/null | awk '{print $1}'`));
manifest[file] = {
size: filestat.size,
sha256: hash
};
}
}
const manifest_file = `${staging_root}/homeproxy-backup-manifest.json`;
writefile(manifest_file, sprintf('%J', manifest));
const backup_list = `${work_dir}/backup.list`;
let all_files = [ ...files, 'homeproxy-backup-manifest.json' ];
writefile(backup_list, join('\n', all_files) + '\n');
const temp_tar = `${work_dir}/archive.tar`;
const temp_gz = `${work_dir}/archive.tar.gz`;
let exit_code = system(`cd ${shellquote(staging_root)} && umask 077 && /bin/tar -cf ${shellquote(temp_tar)} -T ${shellquote(backup_list)}`);
if (exit_code !== 0) {
system(`rm -rf ${shellquote(work_dir)}`);
return { result: false, error: `tar 打包失败,退出码 ${exit_code}` };
}
exit_code = system(`umask 077 && gzip -c ${shellquote(temp_tar)} > ${shellquote(temp_gz)}`);
if (exit_code !== 0) {
system(`rm -rf ${shellquote(work_dir)}`);
return { result: false, error: `压缩失败,退出码 ${exit_code}` };
}
system(`rm -f ${shellquote(path)}`);
exit_code = system(`umask 077 && mv ${shellquote(temp_gz)} ${shellquote(path)} && chmod 600 ${shellquote(path)}`);
system(`rm -rf ${shellquote(work_dir)}`);
if (exit_code !== 0) {
system(`rm -f ${shellquote(path)}`);
return { result: false, error: `生成备份文件失败,退出码 ${exit_code}` };
}
const archive = lstat(path);
if (!archive || archive.type !== 'file' || archive.size <= 0)
return { result: false, error: '备份文件未成功生成' };
return {
result: true,
path,
size: archive.size,
files
};
}
function testExtractBackupArchive(path) {
const restore_test_dir = createWorkDir();
let exit_code = system(`cd ${shellquote(restore_test_dir)} && /bin/tar -xzf ${shellquote(path)} 2>/dev/null`);
if (exit_code !== 0) {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: `备份文件损坏,无法完整解压,退出码 ${exit_code}` };
}
const manifest_path = `${restore_test_dir}/homeproxy-backup-manifest.json`;
const manifest_content = readfile(manifest_path);
if (!manifest_content) {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: '备份文件缺少完整性清单(homeproxy-backup-manifest.json)' };
}
let manifest = {};
try {
manifest = json(manifest_content);
} catch (e) {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: '备份清单格式错误' };
}
for (let file in manifest) {
if (file === 'homeproxy-backup-manifest.json') {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: 'manifest 不能包含自身作为恢复目标' };
}
if (!allowedBackupPath(file)) {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: `manifest 包含不允许的路径: ${file}` };
}
let fullpath = `${restore_test_dir}/${file}`;
let filestat = stat(fullpath);
if (!filestat || filestat.type !== 'file') {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: `备份文件缺失: ${file}` };
}
if (filestat.size !== manifest[file].size) {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: `备份文件大小不匹配: ${file}` };
}
let hash = trim(commandOutput(`sha256sum ${shellquote(fullpath)} 2>/dev/null | awk '{print $1}'`));
if (hash !== manifest[file].sha256) {
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: `备份文件校验和不匹配: ${file}` };
}
}
const fd = popen(`/bin/tar -tzf ${shellquote(path)} 2>&1`);
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
let archive_path = archiveMemberPath(line);
if (!archive_path || archive_path === 'homeproxy-backup-manifest.json')
continue;
if (allowedBackupPath(archive_path) && !manifest[archive_path]) {
fd.close();
system(`rm -rf ${shellquote(restore_test_dir)}`);
return { result: false, error: `归档中存在未记录的可恢复文件: ${archive_path}` };
}
}
fd.close();
}
return { result: true, manifest: manifest, work_dir: restore_test_dir };
}
function resetBackupTargetPaths() {
system('rm -f /etc/config/homeproxy /etc/homeproxy/resources/direct_list.txt /etc/homeproxy/resources/proxy_list.txt');
system('rm -rf /etc/homeproxy/certs && mkdir -p /etc/homeproxy/resources /etc/homeproxy/certs');
}
function extractBackupArchive(work_dir, manifest) {
resetBackupTargetPaths();
for (let file in manifest) {
let src = `${work_dir}/${file}`;
let dst = `/${file}`;
let dst_dir = match(dst, /^(.*)\/[^\/]+$/);
if (dst_dir && dst_dir[1])
system(`mkdir -p ${shellquote(dst_dir[1])}`);
let exit_code = system(`cp ${shellquote(src)} ${shellquote(dst)}`);
if (exit_code !== 0)
return exit_code;
if (match(file, /^etc\/homeproxy\/certs\/[^\/]+$/))
system(`chmod 600 ${shellquote(dst)}`);
else if (file === 'etc/config/homeproxy')
system(`chmod 600 ${shellquote(dst)}`);
else
system(`chmod 644 ${shellquote(dst)}`);
}
return 0;
}
const methods = {
backup_create: {
call: function() {
BACKUP_ARCHIVE = '/tmp/homeproxy-backup-' + generateTempSuffix() + '.tar.gz';
let result = createBackupArchive(BACKUP_ARCHIVE);
if (result.result)
result.download_path = BACKUP_ARCHIVE;
return result;
}
},
backup_get_upload_path: {
call: function() {
const upload_path = '/tmp/homeproxy-restore-' + generateTempSuffix() + '.tar.gz';
return { upload_path: upload_path };
}
},
backup_cleanup: {
args: { path: 'path' },
call: function(req) {
const path = req.args?.path;
if (!path || !match(path, /^\/tmp\/homeproxy-(backup|restore)-[a-f0-9]+\.tar\.gz$/))
return { result: false, error: '无效的临时文件路径' };
system(`rm -f ${shellquote(path)}`);
return { result: true };
}
},
backup_validate: {
args: { path: 'path' },
call: function(req) {
const path = req.args?.path;
if (!path || !match(path, /^\/tmp\/homeproxy-restore-[a-f0-9]+\.tar\.gz$/))
return { valid: false, error: '无效的备份文件路径' };
const check = validateBackupArchive(path);
if (!check.valid)
return check;
const integrity = testExtractBackupArchive(path);
if (integrity.work_dir)
system(`rm -rf ${shellquote(integrity.work_dir)}`);
if (!integrity.result)
return { valid: false, error: integrity.error, files: check.files };
return check;
}
},
backup_restore: {
args: { path: 'path' },
call: function(req) {
const restore_path = req.args?.path;
if (!restore_path)
return { result: false, error: '缺少备份文件路径,请重新上传并验证备份文件' };
if (!match(restore_path, /^\/tmp\/homeproxy-restore-[a-f0-9]+\.tar\.gz$/))
return { result: false, error: '无效的备份文件路径' };
const check = validateBackupArchive(restore_path);
if (!check.valid)
return { result: false, error: check.error, files: check.files };
const extract_check = testExtractBackupArchive(restore_path);
if (!extract_check.result)
return { result: false, error: extract_check.error, files: check.files };
const manifest = extract_check.manifest;
const work_dir = extract_check.work_dir;
const rollback_archive = '/tmp/homeproxy-rollback-' + generateTempSuffix() + '.tar.gz';
const rollback = createBackupArchive(rollback_archive);
if (!rollback.result) {
system(`rm -rf ${shellquote(work_dir)}`);
return { result: false, error: `创建回滚包失败: ${rollback.error}` };
}
const rollback_extract_check = testExtractBackupArchive(rollback_archive);
if (!rollback_extract_check.result) {
system(`rm -rf ${shellquote(work_dir)}`);
return { result: false, error: `回滚包校验失败: ${rollback_extract_check.error}` };
}
const rollback_manifest = rollback_extract_check.manifest;
const rollback_work_dir = rollback_extract_check.work_dir;
let exit_code = extractBackupArchive(work_dir, manifest);
if (exit_code !== 0) {
system(`rm -rf ${shellquote(work_dir)}`);
let rollback_code = extractBackupArchive(rollback_work_dir, rollback_manifest);
system(`rm -rf ${shellquote(rollback_work_dir)}`);
system('/sbin/uci commit homeproxy');
system('/etc/init.d/homeproxy restart >/dev/null 2>&1');
if (rollback_code === 0)
return { result: false, error: `恢复失败,已自动回滚,退出码 ${exit_code}`, rollback: rollback_archive };
return { result: false, error: `恢复失败且自动回滚失败,恢复退出码 ${exit_code},回滚退出码 ${rollback_code}`, rollback: rollback_archive };
}
system(`ucode ${HP_DIR}/scripts/migrate_config.uc >/dev/null 2>&1`);
let adjustments = normalizeRestoredLocalControllers();
system('/sbin/uci commit homeproxy');
system(`rm -f ${shellquote(restore_path)} /tmp/luci-indexcache.*`);
system('rm -rf /tmp/luci-modulecache/ 2>/dev/null');
system('/etc/init.d/homeproxy restart >/dev/null 2>&1');
sleep(2000);
let service_check = validateRestoredService();
if (!service_check.result) {
let rollback_code = extractBackupArchive(rollback_work_dir, rollback_manifest);
system(`rm -rf ${shellquote(work_dir)} ${shellquote(rollback_work_dir)}`);
system('/sbin/uci commit homeproxy');
system('/etc/init.d/homeproxy restart >/dev/null 2>&1');
if (rollback_code === 0)
return { result: false, error: `${service_check.error},已自动回滚`, rollback: rollback_archive };
return { result: false, error: `${service_check.error},且自动回滚失败`, rollback: rollback_archive };
}
system(`rm -rf ${shellquote(work_dir)} ${shellquote(rollback_work_dir)}`);
return {
result: true,
files: check.files,
rollback: rollback_archive,
adjustments
};
}
}
};
return { 'luci.homeproxy_backup': methods };
@@ -1,270 +0,0 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 节点和订阅 helper RPC。
*/
'use strict';
import { readfile, writefile } from 'fs';
import { cursor } from 'uci';
import {
node_filter_compile,
expand_node_filter,
preview_manual_node_allowed
} from '/etc/homeproxy/scripts/node_filter.uc';
import {
collect_node_references,
remove_subscription_nodes
} from '/etc/homeproxy/scripts/node_references.uc';
import { first_value } from '/etc/homeproxy/scripts/clash_api.uc';
function shellquote(s) {
return `'${replace(s, "'", "'\\''")}'`;
}
const HP_DIR = '/etc/homeproxy';
const RUN_DIR = '/var/run/homeproxy';
const CONFIG_DIAGNOSTICS = `${RUN_DIR}/config-diagnostics.json`;
const SUBSCRIPTION_DIAGNOSTICS = `${RUN_DIR}/subscription-diagnostics.json`;
const SUBSCRIPTION_UPDATE_STATUS = `${RUN_DIR}/subscription-update-status.json`;
const MAX_NODE_IDS = 1000;
const MAX_NODE_ID_LEN = 128;
function isEmpty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function readDiagnostics(path) {
let content = readfile(path);
if (!content)
return { result: true, time: null, items: [] };
try {
let payload = json(content);
return {
result: true,
time: payload.time,
items: payload.items || []
};
} catch (e) {
return { result: false, error: '诊断文件格式错误' };
}
}
function combinedDiagnostics() {
let items = [],
latest_time = null;
for (let path in [ CONFIG_DIAGNOSTICS, SUBSCRIPTION_DIAGNOSTICS ]) {
let payload = readDiagnostics(path);
if (!payload.result)
return payload;
if (payload.time && (!latest_time || payload.time > latest_time))
latest_time = payload.time;
for (let item in payload.items)
push(items, item);
}
return {
result: true,
time: latest_time,
items
};
}
function readSubscriptionUpdateStatus() {
let content = readfile(SUBSCRIPTION_UPDATE_STATUS);
if (!content) {
return {
result: true,
running: false,
completed: false,
update_result: null,
error: null,
time: null
};
}
try {
let payload = json(content);
return {
result: true,
running: !!payload.running,
completed: !!payload.completed,
update_result: payload.update_result,
error: payload.error,
time: payload.time
};
} catch (e) {
return { result: false, error: '订阅更新状态文件格式错误' };
}
}
function writeSubscriptionUpdateStatus(payload) {
system(`mkdir -p ${shellquote(RUN_DIR)}`);
writefile(SUBSCRIPTION_UPDATE_STATUS, sprintf('%.J\n', payload));
}
function validNodeId(node_id) {
return type(node_id) === 'string' &&
length(node_id) > 0 &&
length(node_id) <= MAX_NODE_ID_LEN &&
match(node_id, /^[A-Za-z0-9_]+$/);
}
function validateNodeIds(node_ids) {
if (type(node_ids) !== 'array')
return { result: false, error: 'illegal node_ids' };
if (length(node_ids) > MAX_NODE_IDS)
return { result: false, error: 'too many node_ids' };
for (let node_id in node_ids)
if (!validNodeId(node_id))
return { result: false, error: 'illegal node id' };
return { result: true };
}
function expandNodeFilterPreview(manual_nodes, node_filter, node_filter_exclude, node_mode) {
const hpuci = cursor();
hpuci.load('homeproxy');
return expand_node_filter({
uci: hpuci,
config: 'homeproxy',
node_type: 'node',
allow_manual_node: (node_id) => preview_manual_node_allowed(hpuci, 'homeproxy', node_id, node_mode)
}, manual_nodes, node_filter, node_filter_exclude);
}
const methods = {
config_diagnostics: {
args: {},
call: function(_req) {
return combinedDiagnostics();
}
},
update_subscriptions_status: {
args: {},
call: function(_req) {
return readSubscriptionUpdateStatus();
}
},
validate_regex: {
args: { pattern: 'pattern' },
call: function(req) {
const pattern = first_value(req.args?.pattern);
const validated = node_filter_compile(pattern);
if (!validated.result)
return { result: false, error: validated.error, limits: validated.limits };
return { result: true, limits: validated.limits };
}
},
preview_node_filter: {
args: { manual_nodes: [], node_filter: 'node_filter', node_filter_exclude: 'node_filter_exclude', node: 'node' },
call: function(req) {
return expandNodeFilterPreview(
req.args?.manual_nodes,
first_value(req.args?.node_filter),
first_value(req.args?.node_filter_exclude),
first_value(req.args?.node)
);
}
},
node_references: {
args: { node_id: 'node_id' },
call: function(req) {
const node_id = first_value(req.args?.node_id);
if (!validNodeId(node_id))
return { result: false, error: 'illegal node_id' };
const hpuci = cursor();
hpuci.load('homeproxy');
return {
result: true,
node_id: node_id,
references: collect_node_references(hpuci, 'homeproxy', node_id)
};
}
},
remove_subscription_nodes: {
args: { node_ids: [] },
call: function(req) {
const checked = validateNodeIds(req.args?.node_ids);
if (!checked.result)
return checked;
const hpuci = cursor();
hpuci.load('homeproxy');
let result = remove_subscription_nodes(hpuci, 'homeproxy', req.args.node_ids);
hpuci.commit('homeproxy');
return {
result: true,
removed: result.removed,
changed: result.changed,
changes: result.changes,
main_node_fallback: result.main_node_fallback,
main_udp_fallback: result.main_udp_fallback
};
}
},
update_subscriptions: {
args: {},
call: function(_req) {
const status = readSubscriptionUpdateStatus();
if (!status.result)
return status;
if (status.running) {
return {
result: false,
error: '订阅更新正在进行中,请稍候。'
};
}
writeSubscriptionUpdateStatus({
time: time(),
running: true,
completed: false,
update_result: null,
error: null
});
const exit_code = system(`(${HP_DIR}/scripts/update_subscriptions.uc </dev/null >/dev/null 2>&1) &`);
if (exit_code !== 0) {
writeSubscriptionUpdateStatus({
time: time(),
running: false,
completed: true,
update_result: false,
error: '启动订阅更新失败,请查看 HomeProxy 日志。'
});
return {
result: false,
error: '启动订阅更新失败,请查看 HomeProxy 日志。'
};
}
return {
result: true,
started: true
};
}
}
};
return { 'luci.homeproxy_node_tools': methods };
@@ -1,416 +0,0 @@
#!/usr/bin/ucode
'use strict';
import { popen, readfile } from 'fs';
import { cursor } from 'uci';
import { urlencode } from 'luci.http';
import { build_outbound_tag_map, get_outbound_tag } from '/etc/homeproxy/scripts/outbound_tag.uc';
import { parse_controller } from '/etc/homeproxy/scripts/clash_api.uc';
const TCPING_MAX_NODES = 300;
const SECTION_MAX_LEN = 128;
const BATCH_SIZE = 50;
const TCPING_COOLDOWN = 3;
const DELAY_TIMEOUT = 5000;
const FETCH_TIMEOUT = 30000;
const DELAY_TEST_URL = 'https://www.gstatic.com/generate_204';
let last_tcping_at = 0;
// urlencode() 只负责 URL/path 编码,不能作为 shell 注入防护;所有进入 shell 命令的动态值都必须继续经过 shellquote()。
function shellquote(s) {
return `'${replace(s, "'", "'\\''")}'`;
}
function pathSegment(value) {
return replace(urlencode(value), /\+/g, '%20');
}
function controllerBase(hpuci) {
let controller = parse_controller(
hpuci.get('homeproxy', 'config', 'clash_api_external_controller') || '127.0.0.1:9090',
9090
);
let port_num = controller.port || 9090;
if (port_num < 1 || port_num > 65535)
return null;
return 'http://127.0.0.1:' + port_num;
}
function clashApiGet(hpuci, path, timeout) {
let timeout_sec = int(((timeout || FETCH_TIMEOUT) + 999) / 1000),
controller = controllerBase(hpuci),
secret = hpuci.get('homeproxy', 'config', 'clash_api_secret') || '';
if (!controller)
return {};
let command = sprintf('/usr/bin/wget -qO- -T %d --no-proxy %s %s',
timeout_sec,
secret ? sprintf('--header %s', shellquote('Authorization: Bearer ' + secret)) : '',
shellquote(controller + path)),
fd = popen(command + ' 2>/dev/null'),
out = '';
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line'))
out += line;
fd.close();
}
try {
return json(trim(out) || '{}');
} catch (e) {
return {};
}
}
function delayQuery() {
return sprintf('?timeout=%d&url=%s',
DELAY_TIMEOUT,
urlencode(DELAY_TEST_URL));
}
function proxyDelayPath(name) {
return sprintf('/proxies/%s/delay%s',
pathSegment(name),
delayQuery());
}
function delayRequestTimeout() {
let timeout = DELAY_TIMEOUT * 2 + 10000;
return (timeout > FETCH_TIMEOUT) ? timeout : FETCH_TIMEOUT;
}
function parseProxyDelayResult(result) {
let error = '';
if (type(result) !== 'object')
return { status: 'failed', error: '连通性测试无结果' };
error = result.message || result.error || '';
if (type(result.delay) === 'int' || type(result.delay) === 'double')
return { status: 'ok', delay: int(result.delay) };
return { status: 'timeout', error: error || '连通性测试超时或失败' };
}
function stampResult(result) {
let now = time();
result.tested_at = now;
return result;
}
function reserveTcpingSlot() {
let now = time(),
wait = TCPING_COOLDOWN - (now - last_tcping_at);
if (last_tcping_at > 0 && wait > 0)
return {
result: false,
error: sprintf('测速请求过于频繁,请 %d 秒后重试。', wait)
};
last_tcping_at = now;
return null;
}
function loadedProxies(hpuci) {
return clashApiGet(hpuci, '/proxies', FETCH_TIMEOUT);
}
function loadedProxyTags(payload) {
let tags = {};
if (type(payload) === 'object' && type(payload.proxies) === 'object')
for (let name in payload.proxies)
tags[name] = true;
else
return null;
return tags;
}
function nodeTag(tag_map, section) {
return get_outbound_tag(tag_map, section);
}
function runtimeNodeTag(hpuci, tag_map, section) {
let routing_mode = hpuci.get('homeproxy', 'config', 'routing_mode') || 'bypass_mainland_china',
main_node = hpuci.get('homeproxy', 'config', 'main_node') || 'nil',
main_udp_node = hpuci.get('homeproxy', 'config', 'main_udp_node') || 'nil';
if (routing_mode !== 'custom') {
if (section === main_node)
return 'main-out';
if (main_udp_node !== 'nil' &&
main_udp_node !== 'same' &&
main_udp_node !== main_node &&
section === main_udp_node)
return 'main-udp-out';
}
return nodeTag(tag_map, section);
}
function validSectionName(section) {
return type(section) === 'string' &&
section !== '' &&
length(section) <= SECTION_MAX_LEN &&
match(section, /^[A-Za-z0-9_]+$/);
}
function resolveNode(hpuci, tag_map, section) {
if (!validSectionName(section))
return { result: false, error: 'illegal section' };
let cfg = hpuci.get_all('homeproxy', section),
tag = runtimeNodeTag(hpuci, tag_map, section);
if (!cfg || cfg['.type'] !== 'node')
return { result: true, node: stampResult({ status: 'missing', target: tag, error: '节点不存在' }) };
if (cfg.type in ['direct', 'block'])
return {
result: true,
node: stampResult({
status: 'unsupported',
target: tag,
error: '直连或阻断节点不测速'
})
};
return { result: true, node: null, tag };
}
function testNodeByProxyDelay(hpuci, payload, tag) {
let loaded = loadedProxyTags(payload);
if (loaded === null)
return stampResult({ status: 'failed', target: tag, error: 'Clash API 未响应,请确认 HomeProxy 客户端正在运行。' });
if (!(tag in loaded))
return stampResult({ status: 'unloaded', target: tag, error: '节点未加入当前运行配置' });
let result = stampResult(parseProxyDelayResult(clashApiGet(hpuci, proxyDelayPath(tag), delayRequestTimeout())));
result.target = tag;
return result;
}
function testNodesByProxyDelayBatch(hpuci, payload, tags) {
let results = {},
loaded = loadedProxyTags(payload);
if (loaded === null) {
for (let tag in tags)
results[tag] = stampResult({
status: 'failed',
target: tag,
error: 'Clash API 未响应,请确认 HomeProxy 客户端正在运行。'
});
return results;
}
let pending = [];
for (let tag in tags) {
if (!(tag in loaded)) {
results[tag] = stampResult({
status: 'unloaded',
target: tag,
error: '节点未加入当前运行配置'
});
} else {
push(pending, tag);
}
}
if (!length(pending))
return results;
let timeout_sec = int((delayRequestTimeout() + 999) / 1000),
controller = controllerBase(hpuci),
secret = hpuci.get('homeproxy', 'config', 'clash_api_secret') || '',
header = secret ? sprintf('--header %s', shellquote('Authorization: Bearer ' + secret)) : '',
temp_dir_cmd = 'mktemp -d /tmp/homeproxy-delay.XXXXXX',
fd = popen(temp_dir_cmd + ' 2>/dev/null'),
temp_dir = '';
if (fd) {
temp_dir = trim(fd.read('line') || '');
fd.close();
}
if (!temp_dir) {
for (let tag in pending)
results[tag] = stampResult({ status: 'failed', target: tag, error: '无法创建测速临时目录' });
return results;
}
for (let offset = 0; offset < length(pending); offset += BATCH_SIZE) {
let commands = [];
for (let i = offset; i < length(pending) && i < offset + BATCH_SIZE; i++) {
let tag = pending[i],
url = controller + proxyDelayPath(tag),
out = temp_dir + '/' + i + '.json';
// url 与输出路径来自动态值,即使 URL 已编码,拼接 shell 命令前仍必须 shellquote()。
push(commands, sprintf('( /usr/bin/wget -qO- -T %d --no-proxy %s %s > %s 2>/dev/null ) &',
timeout_sec,
header,
shellquote(url),
shellquote(out)));
}
push(commands, 'wait');
system(join(' ', commands));
}
for (let i = 0; i < length(pending); i++) {
let tag = pending[i],
body = trim(readfile(temp_dir + '/' + i + '.json') || ''),
parsed = {};
try {
parsed = json(body || '{}');
} catch (e) {
parsed = {};
}
results[tag] = stampResult(parseProxyDelayResult(parsed));
results[tag].target = tag;
}
system('rm -rf ' + shellquote(temp_dir));
return results;
}
const methods = {
node_tcping: {
args: { section: '' },
call: function(req) {
let section = req.args ? req.args.section : null;
const hpuci = cursor();
hpuci.load('homeproxy');
let tag_map = build_outbound_tag_map(hpuci),
resolved = resolveNode(hpuci, tag_map, section);
if (!resolved.result || resolved.node)
return resolved;
if (hpuci.get('homeproxy', 'config', 'clash_api_enabled') !== '1')
return { result: false, error: '请先开启 Clash API 后再进行连通性测试。' };
if (!controllerBase(hpuci))
return { result: false, error: 'Clash API 端口无效,请设置为 1-65535。' };
let cooldown = reserveTcpingSlot();
if (cooldown)
return cooldown;
let payload = loadedProxies(hpuci);
return {
result: true,
node: testNodeByProxyDelay(hpuci, payload, resolved.tag)
};
}
},
nodes_tcping: {
args: { sections: [] },
call: function(req) {
let sections = req.args ? req.args.sections : null;
if (type(sections) !== 'array')
return { result: false, error: 'illegal sections' };
const hpuci = cursor();
let nodes = {},
tested = 0,
tags = [],
seen_sections = {},
warning = '';
hpuci.load('homeproxy');
let tag_map = build_outbound_tag_map(hpuci);
for (let i = 0; i < length(sections); i++) {
let section = sections[i];
if (seen_sections[section])
continue;
seen_sections[section] = true;
if (++tested > TCPING_MAX_NODES)
nodes[section] = { status: 'skipped', error: sprintf('一次最多测试 %d 个节点', TCPING_MAX_NODES) };
else {
let resolved = resolveNode(hpuci, tag_map, section);
if (!resolved.result)
return { result: false, error: resolved.error };
if (resolved.node)
nodes[section] = resolved.node;
else
push(tags, resolved.tag);
}
}
if (length(tags)) {
if (hpuci.get('homeproxy', 'config', 'clash_api_enabled') !== '1')
return { result: false, error: '请先开启 Clash API 后再进行连通性测试。' };
if (!controllerBase(hpuci))
return { result: false, error: 'Clash API 端口无效,请设置为 1-65535。' };
let cooldown = reserveTcpingSlot();
if (cooldown)
return cooldown;
let payload = loadedProxies(hpuci),
tag_results = testNodesByProxyDelayBatch(hpuci, payload, tags);
if (loadedProxyTags(payload) === null)
warning = 'Clash API 未响应,请确认 HomeProxy 客户端正在运行。';
for (let i = 0; i < length(sections); i++) {
let section = sections[i],
tag = runtimeNodeTag(hpuci, tag_map, section);
if (nodes[section])
continue;
if (tag in tag_results)
nodes[section] = tag_results[tag];
}
}
for (let i = 0; i < length(sections); i++) {
let section = sections[i];
if (seen_sections[section] !== true)
continue;
if (!nodes[section])
nodes[section] = stampResult({
status: 'failed',
target: nodeTag(tag_map, section),
error: '本次未返回结果'
});
}
return { result: true, nodes, warning };
}
}
};
return { 'luci.homeproxy_tcping': methods };