⛄ 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

+10 -1
View File
@@ -33,6 +33,15 @@ config homeproxy 'config'
option ipv6_support '1'
option github_token ''
option log_level 'warn'
option clash_api_enabled '1'
option clash_api_external_controller '127.0.0.1:9090'
option clash_api_default_mode 'Rule'
list clash_api_allow_origin 'https://metacubex.github.io'
list clash_api_allow_origin 'https://metacubexd.pages.dev'
list clash_api_allow_origin 'http://d.metacubex.one'
list clash_api_allow_origin 'https://yacd.metacubex.one'
option clash_api_allow_private_network '1'
option metacubexd_url 'https://metacubexd.pages.dev/#/overview'
config homeproxy 'control'
option lan_proxy_mode 'disabled'
@@ -66,6 +75,7 @@ config homeproxy 'dns'
config homeproxy 'subscription'
option auto_update '0'
option allow_insecure '0'
option allow_unsupported_tls_pin_fallback '0'
option packet_encoding 'xudp'
option update_via_proxy '0'
option filter_nodes 'blacklist'
@@ -75,4 +85,3 @@ config homeproxy 'subscription'
config homeproxy 'server'
option enabled '0'
option log_level 'warn'
@@ -0,0 +1,116 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy Clash API controller 地址 helper。
*/
'use strict';
import { cursor } from 'uci';
function is_empty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
export function first_value(value) {
return (type(value) === 'array') ? value[0] : value;
};
export function strip_cidr(value) {
return replace(trim(first_value(value) || ''), /\/.*$/, '');
};
export function is_ipv4_address(value) {
return !!match(value || '', /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/);
};
export function is_portable_bind_host(value) {
value = lc(trim(value || ''));
return value === 'localhost' ||
value === '0.0.0.0' ||
value === '::' ||
value === '[::]' ||
value === '::1' ||
value === '[::1]' ||
!!match(value, /^127\./);
};
export function append_ipv4_address(addresses, value) {
value = strip_cidr(value);
if (is_ipv4_address(value) && index(addresses, value) === -1)
push(addresses, value);
};
export function local_ipv4_addresses(command_output) {
const netuci = cursor();
let addresses = [];
netuci.load('network');
let lan_ipaddr = netuci.get('network', 'lan', 'ipaddr');
if (type(lan_ipaddr) === 'array') {
for (let addr in lan_ipaddr)
append_ipv4_address(addresses, addr);
} else {
append_ipv4_address(addresses, lan_ipaddr);
}
const ip_addresses = (type(command_output) === 'function')
? (command_output('/sbin/ip -4 -o addr show scope global') || '')
: '';
for (let line in split(ip_addresses, /\n/)) {
let matched = match(line, /[ \t]inet[ \t]+([0-9.]+)(\/[0-9]+)?[ \t]/);
if (matched)
append_ipv4_address(addresses, matched[1]);
}
return addresses;
};
export function parse_controller(value, default_port) {
value = trim(replace(first_value(value) || '', /^https?:\/\//, ''));
value = replace(value, /\/.*$/, '');
let matched = match(value, /^\[([^\]]+)\](:([0-9]+))?$/);
if (matched)
return { host: matched[1], port: int(matched[3] || default_port) };
matched = match(value, /^([^:]+):([0-9]+)$/);
if (matched)
return { host: matched[1], port: int(matched[2]) };
return { host: value, port: int(default_port) };
};
export function format_controller(host, port) {
return sprintf((index(host, ':') >= 0) ? '[%s]:%d' : '%s:%d', host, port);
};
export function normalize_local_controller_option(uci, config, section, local_addresses, fallback_host, option, default_port, set_when_empty) {
let value = uci.get(config, section, option);
if (is_empty(value)) {
if (!set_when_empty)
return null;
let next = format_controller(fallback_host, default_port);
uci.set(config, section, option, next);
return next;
}
let controller = parse_controller(value, default_port),
host = controller.host;
if (is_empty(host))
host = fallback_host;
else if (is_portable_bind_host(host))
return null;
else if (!is_ipv4_address(host) || index(local_addresses, host) !== -1)
return null;
let next = format_controller(fallback_host, controller.port || default_port);
uci.set(config, section, option, next);
return next;
};
@@ -0,0 +1,963 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Clash API display proxy for HomeProxy dashboards.
*/
'use strict';
import * as socket from 'socket';
import * as uloop from 'uloop';
import { cursor } from 'uci';
import { urldecode, urlencode } from 'luci.http';
import { parse_controller } from '/etc/homeproxy/scripts/clash_api.uc';
const uci = cursor();
const uciconfig = 'homeproxy';
const ucimain = 'config';
const shadowtls_suffix = '-out-shadowtls';
const filter_timeout = 10000;
const fallback_delay_limit = 5;
const upstream_read_timeout = 5; // 秒;单次上游读取超时
// Resource limits
const MAX_CONNECTIONS = 64;
const MAX_REQUEST_BUFFER_SIZE = 256 * 1024; // 256KB
const MAX_RESPONSE_BUFFER_SIZE = 8 * 1024 * 1024; // 8MB
const CLIENT_IDLE_TIMEOUT = 30000; // 30 seconds
let next_id = 1;
let connections = {};
let target, listen, server;
uci.load(uciconfig);
function isEmpty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function toArray(value) {
if (type(value) === 'array')
return value;
else if (isEmpty(value))
return [];
return [ value ];
}
function deriveProxyController(target) {
target = parse_controller(target || '127.0.0.1:9090', 9090);
if (isEmpty(target.host))
target.host = '127.0.0.1';
return { host: target.host, port: 9091 };
}
function sendAll(sock, data) {
let offset = 0;
while (offset < length(data)) {
let sent = sock.send(substr(data, offset));
if (sent === null || sent <= 0)
return false;
offset += sent;
}
return true;
}
function recvAvailable(sock) {
let data = '';
let eof = false;
for (let i = 0; i < 32; i++) {
let chunk = sock.recv(16384);
if (chunk === null)
break;
if (length(chunk) === 0) {
eof = true;
break;
}
data += chunk;
if (length(chunk) < 16384)
break;
}
return { data, eof };
}
function contentLength(headers) {
for (let line in headers) {
let matched = match(lc(line), /^content-length:[ \t]*([0-9]+)/);
if (matched)
return int(matched[1]);
}
return null;
}
function headerValue(headers, name) {
name = lc(name);
for (let line in headers) {
let matched = match(line, /^([^:]+):(.*)$/);
if (matched && lc(trim(matched[1])) === name)
return trim(matched[2]);
}
return null;
}
function pathSegment(value) {
return replace(urlencode(value), /\+/g, '%20');
}
function parseHttpMessage(raw, body_to_eof) {
let header_end = index(raw, "\r\n\r\n");
if (header_end < 0)
return null;
let header_text = substr(raw, 0, header_end);
let headers = split(header_text, "\r\n");
let body_start = header_end + 4;
let body_len = contentLength(headers);
if (body_len === null)
body_len = body_to_eof ? length(raw) - body_start : 0;
let complete_len = body_start + body_len;
if (length(raw) < complete_len)
return null;
let request_line = split(headers[0] || '', /[ \t]+/);
return {
raw: substr(raw, 0, complete_len),
headers,
body: substr(raw, body_start, body_len),
complete_len,
method: request_line[0] || '',
path: request_line[1] || '/'
};
}
function shouldFilter(method, path) {
return method === 'GET' && (
path === '/proxies' ||
index(path, '/proxies?') === 0 ||
index(path, '/proxies/') === 0 ||
path === '/providers/proxies' ||
index(path, '/providers/proxies?') === 0 ||
index(path, '/providers/proxies/') === 0
);
}
function isUpgradeRequest(request) {
return !!headerValue(request.headers, 'Upgrade') ||
index(lc(headerValue(request.headers, 'Connection') || ''), 'upgrade') >= 0;
}
function rebuildCloseRequest(request) {
let headers = [];
for (let i = 0; i < length(request.headers); i++) {
let line = request.headers[i],
header = lc(line);
if (i > 0 && match(header, /^(accept-encoding|connection):/))
continue;
push(headers, line);
}
push(headers, 'Accept-Encoding: identity');
push(headers, 'Connection: close');
return join("\r\n", headers) + "\r\n\r\n" + request.body;
}
function isShadowTlsTag(name) {
return type(name) === 'string' &&
length(name) > length(shadowtls_suffix) &&
substr(name, length(name) - length(shadowtls_suffix)) === shadowtls_suffix;
}
function isHiddenProxyTag(name) {
return isShadowTlsTag(name);
}
function filterProxyItem(item) {
if (type(item) !== 'object')
return;
if (type(item.all) === 'array')
item.all = filter(item.all, (name) => !isHiddenProxyTag(name));
}
function filterProxyArray(items) {
return filter(items, (item) => {
if (type(item) === 'string')
return !isHiddenProxyTag(item);
if (type(item) === 'object' && isHiddenProxyTag(item.name))
return false;
filterProxyItem(item);
return true;
});
}
function filterProxiesPayload(payload) {
if (type(payload) !== 'object')
return payload;
filterProxyItem(payload);
if (type(payload.proxies) === 'object') {
for (let name in payload.proxies) {
if (isHiddenProxyTag(name)) {
delete payload.proxies[name];
continue;
}
filterProxyItem(payload.proxies[name]);
}
}
if (type(payload.providers) === 'object') {
for (let name in payload.providers) {
let provider = payload.providers[name];
if (type(provider) !== 'object')
continue;
if (type(provider.proxies) === 'array')
provider.proxies = filterProxyArray(provider.proxies);
}
}
return payload;
}
function parseHex(value) {
let result = 0;
value = lc(trim(split(value, ';', 2)[0] || ''));
for (let i = 0; i < length(value); i++) {
let digit = index('0123456789abcdef', substr(value, i, 1));
if (digit < 0)
return null;
result = result * 16 + digit;
}
return result;
}
function decodeChunkedBody(body) {
let offset = 0;
let decoded = '';
while (offset < length(body)) {
let line_end = index(substr(body, offset), "\r\n");
if (line_end < 0)
return null;
let size = parseHex(substr(body, offset, line_end));
if (size === null)
return null;
offset += line_end + 2;
if (size === 0)
return decoded;
if (length(body) < offset + size + 2)
return null;
decoded += substr(body, offset, size);
offset += size + 2;
}
return null;
}
function rebuildResponse(response, body) {
let headers = [];
for (let i = 0; i < length(response.headers); i++) {
let line = response.headers[i];
if (i > 0 && match(lc(line), /^(content-length|transfer-encoding):/))
continue;
push(headers, line);
}
push(headers, 'Content-Length: ' + length(body));
return join("\r\n", headers) + "\r\n\r\n" + body;
}
function filterResponse(raw) {
let response = parseHttpMessage(raw, true);
if (response === null)
return raw;
let body = response.body;
if (lc(headerValue(response.headers, 'transfer-encoding') || '') === 'chunked') {
body = decodeChunkedBody(substr(raw, response.complete_len - length(response.body)));
if (body === null)
return raw;
}
let payload;
try {
payload = json(body);
} catch (e) {
return raw;
}
if (type(payload) !== 'object')
return raw;
payload = filterProxiesPayload(payload);
return rebuildResponse(response, sprintf('%J', payload));
}
function responseBody(response) {
if (response === null)
return null;
let body = response.body;
if (lc(headerValue(response.headers, 'transfer-encoding') || '') === 'chunked') {
body = decodeChunkedBody(response.body);
if (body === null)
return null;
}
return body;
}
function parseJsonBody(raw) {
if (raw === null)
return null;
let response = parseHttpMessage(raw, true),
body = responseBody(response);
if (body === null)
return null;
try {
return json(body);
} catch (e) {
return null;
}
}
function responseStatus(raw) {
let response = parseHttpMessage(raw, true);
if (response === null)
return 0;
let matched = match(response.headers[0] || '', /^HTTP\/[0-9.]+[ \t]+([0-9]+)/);
return matched ? int(matched[1]) : 0;
}
function responseComplete(raw) {
let header_end = index(raw, "\r\n\r\n");
if (header_end < 0)
return false;
let headers = split(substr(raw, 0, header_end), "\r\n");
if (lc(headerValue(headers, 'transfer-encoding') || '') === 'chunked')
return decodeChunkedBody(substr(raw, header_end + 4)) !== null;
if (contentLength(headers) === null)
return false;
return parseHttpMessage(raw) !== null;
}
function closeConnection(conn) {
if (!conn)
return;
if (conn.client_handle) {
conn.client_handle.delete();
conn.client_handle = null;
}
if (conn.upstream_handle) {
conn.upstream_handle.delete();
conn.upstream_handle = null;
}
if (conn.timer) {
conn.timer.cancel();
conn.timer = null;
}
if (conn.idle_timer) {
conn.idle_timer.cancel();
conn.idle_timer = null;
}
if (conn.client) {
conn.client.close();
conn.client = null;
}
if (conn.upstream) {
conn.upstream.close();
conn.upstream = null;
}
delete connections[conn.id];
}
function isEmptyObject(value) {
return type(value) === 'object' && length(value) === 0;
}
function buildJsonResponse(request, payload) {
let body = sprintf('%J', payload),
headers = [
'HTTP/1.1 200 OK',
'Content-Type: application/json',
'Connection: close'
],
origin = headerValue(request.headers, 'Origin');
if (!isEmpty(origin)) {
let allowed_origins = toArray(uci.get(uciconfig, ucimain, 'clash_api_allow_origin'));
if (index(allowed_origins, origin) >= 0) {
push(headers, 'Access-Control-Allow-Origin: ' + origin);
push(headers, 'Vary: Origin');
}
}
push(headers, 'Content-Length: ' + length(body));
return join("\r\n", headers) + "\r\n\r\n" + body;
}
function parseGroupDelayPath(path) {
let parts = split(path, '?', 2),
matched = match(parts[0] || '', /^\/group\/(.+)\/delay$/);
if (!matched)
return null;
return {
name: urldecode(matched[1]),
query: (length(parts) > 1) ? ('?' + parts[1]) : ''
};
}
function upstreamHeader(request, name) {
let value = headerValue(request.headers, name);
return isEmpty(value) ? null : value;
}
function upstreamRequest(method, path, request, body) {
body = body || '';
let headers = [
sprintf('%s %s HTTP/1.1', method, path),
sprintf('Host: %s:%d', target.host, target.port),
'Accept-Encoding: identity',
'Connection: close'
],
authorization = upstreamHeader(request, 'Authorization'),
origin = upstreamHeader(request, 'Origin');
if (authorization)
push(headers, 'Authorization: ' + authorization);
if (origin)
push(headers, 'Origin: ' + origin);
if (length(body)) {
let content_type = upstreamHeader(request, 'Content-Type');
if (content_type)
push(headers, 'Content-Type: ' + content_type);
push(headers, 'Content-Length: ' + length(body));
}
return join("\r\n", headers) + "\r\n\r\n" + body;
}
function fetchUpstreamAsync(method, path, request, body, done) {
let upstream = socket.connect(target.host, target.port, null, 3000);
if (upstream === null) {
done(null);
return null;
}
let fetch = {
upstream: upstream,
handle: null,
timer: null,
raw: '',
finished: false
};
function finish(raw) {
if (fetch.finished)
return;
fetch.finished = true;
if (fetch.handle) {
fetch.handle.delete();
fetch.handle = null;
}
if (fetch.timer) {
fetch.timer.cancel();
fetch.timer = null;
}
if (fetch.upstream) {
fetch.upstream.close();
fetch.upstream = null;
}
done(raw);
}
if (!sendAll(upstream, upstreamRequest(method, path, request, body))) {
finish(null);
return fetch;
}
fetch.timer = uloop.timer(upstream_read_timeout * 1000, () => finish(null));
fetch.handle = uloop.handle(upstream, (events, eof, error) => {
if (events & uloop.ULOOP_READ) {
let received = recvAvailable(upstream);
if (length(received.data)) {
if (length(fetch.raw) + length(received.data) > MAX_RESPONSE_BUFFER_SIZE) {
warn(sprintf('homeproxy clash api proxy: async fetch response exceeded %d bytes\n', MAX_RESPONSE_BUFFER_SIZE));
finish(null);
return;
}
fetch.raw += received.data;
}
if (responseComplete(fetch.raw) || received.eof) {
finish(length(fetch.raw) ? fetch.raw : null);
return;
}
}
if (eof || error)
finish(length(fetch.raw) ? fetch.raw : null);
}, uloop.ULOOP_READ);
return fetch;
}
function fetchVisibleProxyGroupAsync(group_name, request, done) {
fetchUpstreamAsync('GET', '/proxies', request, null, (raw) => {
if (raw === null) {
done(null);
return;
}
let payload = filterProxiesPayload(parseJsonBody(raw));
if (type(payload) !== 'object' || type(payload.proxies) !== 'object') {
done(null);
return;
}
let group = payload.proxies[group_name];
done((type(group) === 'object') ? group : null);
});
}
function testProxyDelayAsync(proxy_name, query, request, done) {
fetchUpstreamAsync('GET',
'/proxies/' + pathSegment(proxy_name) + '/delay' + query,
request,
null,
(raw) => {
if (raw === null || responseStatus(raw) < 200 || responseStatus(raw) >= 300) {
done(0);
return;
}
let payload = parseJsonBody(raw);
if (type(payload) === 'object' && type(payload.delay) === 'double') {
done(int(payload.delay));
return;
}
if (type(payload) === 'object' && type(payload.delay) === 'int') {
done(payload.delay);
return;
}
done(0);
});
}
function fallbackGroupDelayAsync(group_info, request, progress, done) {
fetchVisibleProxyGroupAsync(group_info.name, request, (group) => {
if (progress)
progress();
if (group === null || type(group.all) !== 'array') {
done(null);
return;
}
let results = {},
candidates = [],
truncated = false;
for (let proxy_name in group.all) {
if (isShadowTlsTag(proxy_name))
continue;
if (length(candidates) >= fallback_delay_limit) {
truncated = true;
break;
}
push(candidates, proxy_name);
}
if (truncated)
warn(sprintf('homeproxy clash api proxy: fallback delay for group %s limited to first %d visible proxies\n',
group_info.name, fallback_delay_limit));
function next(index) {
if (index >= length(candidates)) {
done(results);
return;
}
let proxy_name = candidates[index];
testProxyDelayAsync(proxy_name, group_info.query, request, (delay) => {
results[proxy_name] = delay;
if (progress)
progress();
next(index + 1);
});
}
next(0);
});
}
function finishGroupDelay(conn, request, group_info, raw) {
if (!connections[conn.id])
return;
let payload = parseJsonBody(raw),
status = raw === null ? 0 : responseStatus(raw);
if (type(payload) === 'object')
payload = filterProxiesPayload(payload);
if (status >= 200 && status < 300 && !isEmptyObject(payload)) {
sendAll(conn.client, buildJsonResponse(request, payload));
closeConnection(conn);
return;
}
resetTimer(conn);
fallbackGroupDelayAsync(group_info, request, () => resetTimer(conn), (fallback_payload) => {
if (!connections[conn.id])
return;
if (fallback_payload !== null) {
sendAll(conn.client, buildJsonResponse(request, fallback_payload));
closeConnection(conn);
return;
}
if (raw !== null)
sendAll(conn.client, raw);
closeConnection(conn);
});
}
function handleGroupDelay(conn, request, group_info) {
if (conn.client_handle) {
conn.client_handle.delete();
conn.client_handle = null;
}
conn.request_buffer = '';
conn.timer = uloop.timer(filter_timeout, () => closeConnection(conn));
fetchUpstreamAsync(request.method, request.path, request, request.body,
(raw) => finishGroupDelay(conn, request, group_info, raw));
return true;
}
function resetTimer(conn) {
if (conn.timer)
conn.timer.set(filter_timeout);
}
function relayRead(conn, from, to) {
let received = recvAvailable(from);
if (length(received.data) && !sendAll(to, received.data)) {
closeConnection(conn);
return;
}
// Reset idle timer on data activity
if (conn.idle_timer && length(received.data) > 0) {
conn.idle_timer.set(CLIENT_IDLE_TIMEOUT);
}
if (received.eof)
closeConnection(conn);
}
function setupRelay(conn) {
// Set idle timer for relay phase (30s idle timeout)
conn.idle_timer = uloop.timer(CLIENT_IDLE_TIMEOUT, () => {
warn(`Connection ${conn.id} idle timeout during relay\n`);
closeConnection(conn);
});
conn.client_handle = uloop.handle(conn.client, (events, eof, error) => {
if (events & uloop.ULOOP_READ)
relayRead(conn, conn.client, conn.upstream);
if (eof || error)
closeConnection(conn);
}, uloop.ULOOP_READ);
conn.upstream_handle = uloop.handle(conn.upstream, (events, eof, error) => {
if (events & uloop.ULOOP_READ)
relayRead(conn, conn.upstream, conn.client);
if (eof || error)
closeConnection(conn);
}, uloop.ULOOP_READ);
}
function finishFilteredResponse(conn) {
sendAll(conn.client, filterResponse(conn.response_buffer));
closeConnection(conn);
}
function setupFilteredResponse(conn) {
conn.response_buffer = '';
conn.timer = uloop.timer(filter_timeout, () => closeConnection(conn));
conn.upstream_handle = uloop.handle(conn.upstream, (events, eof, error) => {
if (events & uloop.ULOOP_READ) {
let received = recvAvailable(conn.upstream);
if (length(received.data)) {
// Check response buffer size limit
if (length(conn.response_buffer) + length(received.data) > MAX_RESPONSE_BUFFER_SIZE) {
warn(`Connection ${conn.id} response buffer exceeded ${MAX_RESPONSE_BUFFER_SIZE} bytes\n`);
closeConnection(conn);
return;
}
conn.response_buffer += received.data;
resetTimer(conn);
}
if (responseComplete(conn.response_buffer) || received.eof) {
finishFilteredResponse(conn);
return;
}
}
if (eof || error) {
if (length(conn.response_buffer))
finishFilteredResponse(conn);
else
closeConnection(conn);
}
}, uloop.ULOOP_READ);
}
function startUpstream(conn, request) {
try {
let group_delay = parseGroupDelayPath(request.path);
if (request.method === 'GET' && group_delay !== null)
return handleGroupDelay(conn, request, group_delay);
if (conn.client_handle) {
conn.client_handle.delete();
conn.client_handle = null;
}
conn.filter_read = shouldFilter(request.method, request.path);
conn.upstream = socket.connect(target.host, target.port, null, 3000);
if (conn.upstream === null) {
warn(sprintf('homeproxy clash api proxy: connect to %s:%d failed: %s\n',
target.host, target.port, socket.error()));
closeConnection(conn);
return;
}
let upstream_request = (conn.filter_read || !isUpgradeRequest(request))
? rebuildCloseRequest(request)
: conn.request_buffer;
if (!sendAll(conn.upstream, upstream_request)) {
warn('homeproxy clash api proxy: failed to send upstream request\n');
closeConnection(conn);
return;
}
conn.request_buffer = '';
if (conn.filter_read)
setupFilteredResponse(conn);
else
setupRelay(conn);
} catch (e) {
warn(sprintf('homeproxy clash api proxy: start upstream exception: %J\n', e));
closeConnection(conn);
}
}
function onClientRequest(conn, events, eof, error) {
if (events & uloop.ULOOP_READ) {
let received = recvAvailable(conn.client);
if (length(received.data)) {
// Check request buffer size limit
if (length(conn.request_buffer) + length(received.data) > MAX_REQUEST_BUFFER_SIZE) {
warn(`Connection ${conn.id} request buffer exceeded ${MAX_REQUEST_BUFFER_SIZE} bytes\n`);
closeConnection(conn);
return;
}
conn.request_buffer += received.data;
}
let request = parseHttpMessage(conn.request_buffer);
if (request !== null) {
// Cancel idle timer when request is complete
if (conn.idle_timer) {
conn.idle_timer.cancel();
delete conn.idle_timer;
}
startUpstream(conn, request);
return;
}
if (received.eof) {
closeConnection(conn);
return;
}
}
if (eof || error)
closeConnection(conn);
}
function acceptClients(server) {
while (true) {
// Check connection limit
let active_connections = length(keys(connections));
if (active_connections >= MAX_CONNECTIONS) {
// Accept and immediately close to reject (don't leave in backlog)
let rejected = server.accept();
if (rejected) {
rejected.close();
}
warn(`Connection limit reached: ${active_connections}/${MAX_CONNECTIONS}\n`);
break;
}
let client = server.accept();
if (client === null)
break;
let conn = {
id: next_id++,
client,
upstream: null,
client_handle: null,
upstream_handle: null,
timer: null,
request_buffer: '',
response_buffer: '',
created_at: time()
};
connections[conn.id] = conn;
// Set idle timeout (uloop.timer signature: timeout_ms, callback)
conn.idle_timer = uloop.timer(CLIENT_IDLE_TIMEOUT, () => {
warn(`Connection ${conn.id} idle timeout\n`);
closeConnection(conn);
});
conn.client_handle = uloop.handle(client, (events, eof, error) => onClientRequest(conn, events, eof, error), uloop.ULOOP_READ);
}
}
const clash_api_enabled = uci.get(uciconfig, ucimain, 'clash_api_enabled') || '0';
if (clash_api_enabled !== '1')
exit(0);
target = parse_controller(uci.get(uciconfig, ucimain, 'clash_api_external_controller') || '127.0.0.1:9090', 9090);
listen = isEmpty(uci.get(uciconfig, ucimain, 'clash_api_proxy_external_controller'))
? deriveProxyController((index(target.host, ':') >= 0 ? '[' + target.host + ']' : target.host) + ':' + target.port)
: parse_controller(uci.get(uciconfig, ucimain, 'clash_api_proxy_external_controller'), 9091);
if (isEmpty(listen.host))
listen.host = '127.0.0.1';
server = socket.listen(listen.host, listen.port, null, 128, true);
if (server === null) {
warn(sprintf('homeproxy clash api proxy: failed to listen on %s:%d: %s\n', listen.host, listen.port, socket.error()));
exit(1);
}
uloop.init();
uloop.handle(server, () => acceptClients(server), uloop.ULOOP_READ);
warn(sprintf('homeproxy clash api proxy: listening on %s:%d, forwarding to %s:%d\n',
listen.host, listen.port, target.host, target.port));
uloop.run();
@@ -57,17 +57,25 @@ const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
let outbound_node, outbound_udp_node, default_outbound, china_dns_server, bypass_cn_traffic;
default_outbound = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
if (routing_mode !== 'custom') {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
if (isEmpty(outbound_node))
outbound_node = default_outbound;
} else {
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
outbound_node = default_outbound;
}
const use_default_outbound_routing = (routing_mode !== 'custom') &&
isEmpty(uci.get(cfgname, 'config', 'main_node') || 'nil') &&
!isEmpty(default_outbound);
let routing_port = uci.get(cfgname, 'config', 'routing_port');
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
@@ -84,7 +92,7 @@ if (match(proxy_mode, /redirect/)) {
redirect_port = uci.get(cfgname, 'infra', 'redirect_port') || '5331';
}
if (match(proxy_mode, /tproxy/))
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
if (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing) {
tproxy_port = uci.get(cfgname, 'infra', 'tproxy_port') || '5332';
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101');
}
@@ -376,7 +384,7 @@ chain homeproxy_output_redir {
{% endif %}
{# UDP tproxy #}
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %}
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing)): %}
chain homeproxy_mangle_tproxy {
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
{% if (ipv6_support === '1'): %}
@@ -15,10 +15,9 @@ const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainl
let outbound_node, tun_name;
if (match(proxy_mode, /tun/)) {
if (routing_mode === 'custom')
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node === 'nil')
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
else
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node !== 'nil')
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
@@ -14,15 +14,83 @@ import { cursor } from 'uci';
import {
isEmpty, parseURL, strToBool, strToInt, strToTime,
removeBlankAttrs, validation, HP_DIR, RUN_DIR
removeBlankAttrs, validation, HP_DIR as DEFAULT_HP_DIR, RUN_DIR as DEFAULT_RUN_DIR
} from 'homeproxy';
import {
apply_outbound_tag_rename as applyOutboundTagRenameHelper,
assert_unique_outbound_tags as assertUniqueOutboundTagsHelper,
assert_no_stale_outbound_tags as assertNoStaleOutboundTagsHelper,
build_outbound_tag_map,
get_outbound_tag,
get_shadowtls_outbound_tag,
get_fallback_outbound_tag
} from 'outbound_tag';
import { expand_node_filter as expandNodeFilterHelper } from 'node_filter';
import * as routingTarget from 'routing_target';
const ubus = connect();
function testRootArg() {
const argv = (type(ARGV) === 'array') ? ARGV : [];
for (let i = 0; i < length(argv); i++)
if (argv[i] === '--test-root' && !isEmpty(argv[i + 1]))
return argv[i + 1];
return null;
}
const TEST_ROOT = testRootArg();
const HP_DIR = TEST_ROOT ? TEST_ROOT + '/etc/homeproxy' : DEFAULT_HP_DIR;
const RUN_DIR = TEST_ROOT ? TEST_ROOT + '/var/run/homeproxy' : DEFAULT_RUN_DIR;
const DIAGNOSTICS_PATH = RUN_DIR + '/config-diagnostics.json';
/* 配置错误与诊断收集 */
let config_errors = [];
function reportError(type, message, suggestion) {
push(config_errors, {
type: type, /* 'error', 'warning' */
message: message,
suggestion: suggestion
});
}
function hasErrors() {
for (let err in config_errors)
if (err.type === 'error')
return true;
return false;
}
function formatErrors() {
let output = '';
for (let err in config_errors) {
output += sprintf('[%s] %s\n', uc(err.type), err.message);
if (err.suggestion)
output += sprintf('建议: %s\n', err.suggestion);
output += '\n';
}
return output;
}
function writeDiagnostics() {
system('mkdir -p ' + RUN_DIR);
if (length(config_errors)) {
writefile(DIAGNOSTICS_PATH, sprintf('%.J\n', {
time: time(),
items: config_errors
}));
} else {
system('rm -f ' + DIAGNOSTICS_PATH);
}
}
const ubus = TEST_ROOT ? null : connect();
/* const features = ubus.call('luci.homeproxy', 'singbox_get_features') || {}; */
/* UCI config start */
const uci = cursor();
const uci = TEST_ROOT ? cursor(TEST_ROOT + '/etc/config') : cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
@@ -43,8 +111,17 @@ const ucinode = 'node';
const uciruleset = 'ruleset';
const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainland_china';
const routing_target_max_depth = 20;
const routing_target_ctx = {
uci: uci,
config: uciconfig,
node_type: ucinode,
tag_map: build_outbound_tag_map(uci),
max_depth: routing_target_max_depth,
reportError: reportError
};
let wan_dns = ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0];
let wan_dns = TEST_ROOT ? null : ubus.call('network.interface', 'status', {'interface': 'wan'})?.['dns-server']?.[0];
if (!wan_dns)
wan_dns = (routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5';
@@ -60,6 +137,20 @@ let main_node, main_udp_node, dedicated_udp_node, default_outbound, default_outb
dns_client_subnet, cache_file_store_rdrc, cache_file_rdrc_timeout, direct_domain_list,
proxy_domain_list;
/* Routing / DNS advanced settings may still be in use when非 custom模式下 main_node 为空 */
default_outbound = uci.get(uciconfig, uciroutingsetting, 'default_outbound') || 'nil';
default_outbound_dns = uci.get(uciconfig, uciroutingsetting, 'default_outbound_dns') || 'default-dns';
domain_strategy = uci.get(uciconfig, uciroutingsetting, 'domain_strategy');
dns_default_strategy = uci.get(uciconfig, ucidnssetting, 'default_strategy') ||
uci.get(uciconfig, ucidnssetting, 'dns_strategy');
dns_default_server = uci.get(uciconfig, ucidnssetting, 'default_server') || 'default-dns';
dns_disable_cache = uci.get(uciconfig, ucidnssetting, 'disable_cache');
dns_disable_cache_expire = uci.get(uciconfig, ucidnssetting, 'disable_cache_expire');
dns_independent_cache = uci.get(uciconfig, ucidnssetting, 'independent_cache');
dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet');
cache_file_store_rdrc = uci.get(uciconfig, ucidnssetting, 'cache_file_store_rdrc');
cache_file_rdrc_timeout = uci.get(uciconfig, ucidnssetting, 'cache_file_rdrc_timeout');
if (routing_mode !== 'custom') {
main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil';
main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil';
@@ -74,7 +165,8 @@ if (routing_mode !== 'custom') {
if (isEmpty(china_dns_server) || type(china_dns_server) !== 'string' || china_dns_server === 'wan')
china_dns_server = wan_dns;
}
dns_default_strategy = (ipv6_support !== '1') ? 'ipv4_only' : null;
if (!isEmpty(main_node))
dns_default_strategy = (ipv6_support !== '1') ? 'ipv4_only' : null;
direct_domain_list = trim(readfile(HP_DIR + '/resources/direct_list.txt'));
if (direct_domain_list)
@@ -84,25 +176,14 @@ if (routing_mode !== 'custom') {
if (proxy_domain_list)
proxy_domain_list = split(proxy_domain_list, /[\r\n]/);
sniff_override = uci.get(uciconfig, uciinfra, 'sniff_override') || '1';
sniff_override = uci.get(uciconfig, uciinfra, 'sniff_override') ||
uci.get(uciconfig, uciroutingsetting, 'sniff_override') || '1';
} else {
/* DNS settings */
dns_default_strategy = uci.get(uciconfig, ucidnssetting, 'default_strategy');
dns_default_server = uci.get(uciconfig, ucidnssetting, 'default_server');
dns_disable_cache = uci.get(uciconfig, ucidnssetting, 'disable_cache');
dns_disable_cache_expire = uci.get(uciconfig, ucidnssetting, 'disable_cache_expire');
dns_independent_cache = uci.get(uciconfig, ucidnssetting, 'independent_cache');
dns_client_subnet = uci.get(uciconfig, ucidnssetting, 'client_subnet');
cache_file_store_rdrc = uci.get(uciconfig, ucidnssetting, 'cache_file_store_rdrc'),
cache_file_rdrc_timeout = uci.get(uciconfig, ucidnssetting, 'cache_file_rdrc_timeout');
/* Routing settings */
default_outbound = uci.get(uciconfig, uciroutingsetting, 'default_outbound') || 'nil';
default_outbound_dns = uci.get(uciconfig, uciroutingsetting, 'default_outbound_dns') || 'default-dns';
domain_strategy = uci.get(uciconfig, uciroutingsetting, 'domain_strategy');
sniff_override = uci.get(uciconfig, uciroutingsetting, 'sniff_override');
}
const use_default_outbound_routing = isEmpty(main_node) && !isEmpty(default_outbound);
const proxy_mode = uci.get(uciconfig, ucimain, 'proxy_mode') || 'redirect_tproxy',
default_interface = uci.get(uciconfig, ucicontrol, 'bind_interface');
@@ -122,7 +203,7 @@ if (match(proxy_mode, /redirect/)) {
redirect_port = uci.get(uciconfig, 'infra', 'redirect_port') || '5331';
}
if (match(proxy_mode, /tproxy/))
if (main_udp_node !== 'nil' || routing_mode === 'custom')
if (main_udp_node !== 'nil' || routing_mode === 'custom' || use_default_outbound_routing)
tproxy_port = uci.get(uciconfig, 'infra', 'tproxy_port') || '5332';
if (match(proxy_mode, /tun/)) {
tun_name = uci.get(uciconfig, uciinfra, 'tun_name') || 'singtun0';
@@ -137,6 +218,14 @@ if (match(proxy_mode, /tun/)) {
}
const log_level = uci.get(uciconfig, ucimain, 'log_level') || 'warn';
const clash_api_enabled = uci.get(uciconfig, ucimain, 'clash_api_enabled') || '0',
clash_api_external_controller = uci.get(uciconfig, ucimain, 'clash_api_external_controller') || '127.0.0.1:9090',
clash_api_secret = uci.get(uciconfig, ucimain, 'clash_api_secret'),
clash_api_default_mode = uci.get(uciconfig, ucimain, 'clash_api_default_mode') || 'Rule',
clash_api_allow_origin = uci.get(uciconfig, ucimain, 'clash_api_allow_origin') || [],
clash_api_allow_private_network = uci.get(uciconfig, ucimain, 'clash_api_allow_private_network') || '1';
/* UCI config end */
/* Config helper start */
@@ -180,13 +269,36 @@ function parse_dnsquery(strquery) {
}
function generate_endpoint(node) {
function section_outbound_tag(section) {
return get_fallback_outbound_tag(section);
}
function node_outbound_tag(node_or_section) {
let section = (type(node_or_section) === 'object') ? node_or_section['.name'] : node_or_section;
return section_outbound_tag(section);
}
function runtime_outbound_tag(node_or_section) {
let section = (type(node_or_section) === 'object') ? node_or_section['.name'] : node_or_section;
return get_outbound_tag(routing_target_ctx.tag_map, section);
}
function runtime_shadowtls_tag(node_or_section) {
let section = (type(node_or_section) === 'object') ? node_or_section['.name'] : node_or_section;
return get_shadowtls_outbound_tag(routing_target_ctx.tag_map, section);
}
function map_outbound_tags(node_ids) {
return map(node_ids, (node_id) => node_outbound_tag(node_id));
}
function generate_endpoint(node, tag) {
if (type(node) !== 'object' || isEmpty(node))
return null;
const endpoint = {
type: node.type,
tag: 'cfg-' + node['.name'] + '-out',
tag: tag || node_outbound_tag(node),
address: node.wireguard_local_address,
mtu: strToInt(node.wireguard_mtu),
private_key: node.wireguard_private_key,
@@ -213,13 +325,13 @@ function generate_endpoint(node) {
return endpoint;
}
function generate_outbound(node) {
function generate_outbound(node, tag) {
if (type(node) !== 'object' || isEmpty(node))
return null;
const outbound = {
type: node.type,
tag: 'cfg-' + node['.name'] + '-out',
tag: tag || node_outbound_tag(node),
routing_mark: strToInt(self_mark),
server: node.address,
@@ -278,7 +390,6 @@ function generate_outbound(node) {
global_padding: strToBool(node.vmess_global_padding),
authenticated_length: strToBool(node.vmess_authenticated_length),
packet_encoding: node.packet_encoding,
multiplex: (node.multiplex === '1') ? {
enabled: true,
protocol: node.multiplex_protocol,
@@ -343,33 +454,171 @@ function generate_outbound(node) {
return outbound;
}
function get_outbound(cfg) {
if (isEmpty(cfg))
function generate_shadowtls_outbound(node, tag) {
if (type(node) !== 'object' || node.shadowtls_enabled === '0' || isEmpty(node.shadowtls_address))
return null;
if (type(cfg) === 'array') {
if ('any-out' in cfg)
return 'any';
let outbounds = [];
for (let i in cfg)
push(outbounds, get_outbound(i));
return outbounds;
} else {
switch (cfg) {
case 'block-out':
case 'direct-out':
return cfg;
default:
const node = uci.get(uciconfig, cfg, 'node');
if (isEmpty(node))
die(sprintf("%s's node is missing, please check your configuration.", cfg));
else if (node === 'urltest')
return 'cfg-' + cfg + '-out';
else
return 'cfg-' + node + '-out';
return {
type: 'shadowtls',
tag: tag,
routing_mark: strToInt(self_mark),
server: node.shadowtls_address,
server_port: strToInt(node.shadowtls_port),
version: strToInt(node.shadowtls_version),
password: node.shadowtls_password,
tls: {
enabled: true,
server_name: node.shadowtls_sni
}
};
}
function has_shadowtls_detour(node) {
return type(node) === 'object' &&
node.type === 'shadowsocks' &&
node.shadowtls_enabled !== '0' &&
!isEmpty(node.shadowtls_address);
}
function apply_routing_node_options(outbound, cfg) {
if (type(outbound) !== 'object' || type(cfg) !== 'object' || isEmpty(cfg))
return;
outbound.bind_interface = cfg.bind_interface;
if (cfg.outbound) {
if (routingTarget.routing_node_has_path(routing_target_ctx, cfg.outbound, cfg['.name'], {}))
reportError('error',
sprintf('路由节点配置错误:%s 的上游出站与自身形成循环引用。', cfg.label || cfg['.name']),
'进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,检查该节点的"上游出站"配置');
else
outbound.detour = get_outbound(cfg.outbound, 'block-out',
sprintf('路由节点 %s 的上游出站', cfg.label || cfg['.name']));
}
if (cfg.domain_resolver)
outbound.domain_resolver = {
server: get_resolver(cfg.domain_resolver),
strategy: cfg.domain_strategy
};
}
function push_node_outbound(client_config, node, tag, routing_cfg) {
if (type(node) !== 'object' || isEmpty(node))
return;
if (node.type === 'wireguard') {
push(client_config.endpoints, generate_endpoint(node, tag));
apply_routing_node_options(client_config.endpoints[length(client_config.endpoints)-1], routing_cfg);
} else {
let outbound = generate_outbound(node, tag);
if (has_shadowtls_detour(node)) {
const shadowtls_tag = (tag === node_outbound_tag(node))
? runtime_shadowtls_tag(node)
: tag + '-shadowtls';
let shadowtls_outbound = generate_shadowtls_outbound(node, shadowtls_tag);
apply_routing_node_options(shadowtls_outbound, routing_cfg);
outbound.detour = shadowtls_tag;
push(client_config.outbounds, shadowtls_outbound);
} else {
apply_routing_node_options(outbound, routing_cfg);
}
push(client_config.outbounds, outbound);
}
}
function push_block_outbound(client_config, tag) {
push(client_config.outbounds, {
type: 'block',
tag: tag
});
}
function is_builtin_outbound(target) {
return routingTarget.is_builtin_outbound(routing_target_ctx, target);
}
function is_node_section(target) {
return routingTarget.is_node_section(routing_target_ctx, target);
}
function is_routing_node_section(target) {
return routingTarget.is_routing_node_section(routing_target_ctx, target);
}
function expand_node_filter(manual_nodes, node_filter, node_filter_exclude, owner, allow_routing_node) {
let result = expandNodeFilterHelper({
uci: uci,
config: uciconfig,
node_type: ucinode,
allow_manual_node: (node_id) => {
if (is_node_section(node_id) ||
(allow_routing_node && (is_builtin_outbound(node_id) || is_routing_node_section(node_id))))
return true;
return false;
},
on_invalid_manual_node: (node_id) => {
reportError('warning',
sprintf('节点组 %s 引用了已删除的节点:%s,已在本次生成中跳过。', owner, node_id),
'请进入 LuCI 界面检查对应的节点列表引用');
}
}, manual_nodes, node_filter, node_filter_exclude);
if (!result.result) {
if (!isEmpty(node_filter) && !isEmpty(node_filter_exclude)) {
reportError('error',
sprintf('路由节点 %s 的节点正则或排除正则无效:%s', owner, result.error),
'请修正节点正则/排除正则,或清空该字段后重新生成配置');
} else if (!isEmpty(node_filter)) {
reportError('error',
sprintf('路由节点 %s 的节点正则无效:%s', owner, result.error),
'请修正节点正则,或清空该字段后重新生成配置');
} else {
reportError('error',
sprintf('路由节点 %s 的排除正则无效:%s', owner, result.error),
'请修正排除正则,或清空该字段后重新生成配置');
}
return [];
}
if (result.truncated)
reportError('warning',
sprintf('路由节点 %s 的节点正则命中超过 %d 个节点,已截断结果。', owner, result.max_result_nodes),
'请收窄节点正则或拆分路由节点,避免一次生成过大的 outbound 组');
if (result.scan_truncated)
reportError('warning',
sprintf('路由节点 %s 的节点正则扫描超过 %d 个节点,已停止继续匹配。', owner, result.max_scan_nodes),
'请收窄订阅节点规模或正则范围,避免生成器长时间执行复杂匹配');
return result.nodes || [];
}
function resolve_outbound_target(target, owner, seen_path) {
return routingTarget.resolve_outbound_target(routing_target_ctx, target, owner, seen_path);
}
function get_routing_target_outbound(target, owner) {
return routingTarget.get_routing_target_outbound(routing_target_ctx, target, owner);
}
function push_routing_target_outbound(client_config, target, routing_nodes) {
return routingTarget.collect_routing_target_dependencies(
routing_target_ctx,
client_config,
target,
routing_nodes,
push_node_outbound,
node_outbound_tag
);
}
function get_valid_selector_outbounds(selector_nodes, owner, owner_section) {
return routingTarget.get_valid_selector_outbounds(routing_target_ctx, selector_nodes, owner, owner_section);
}
function get_outbound(cfg, fallback, owner) {
return routingTarget.get_outbound(routing_target_ctx, cfg, fallback, owner);
}
function get_resolver(cfg) {
@@ -394,6 +643,26 @@ function get_ruleset(cfg) {
push(rules, isEmpty(i) ? null : 'cfg-' + i + '-rule');
return rules;
}
function apply_outbound_tag_rename(client_config) {
return applyOutboundTagRenameHelper(client_config, routing_target_ctx.tag_map);
}
function assert_unique_outbound_tags(client_config) {
return assertUniqueOutboundTagsHelper(client_config, (tag) => {
reportError('error',
sprintf('rename 后出现重复 outbound tag: %s', tag),
'通常意味着去重算法异常;请附带节点 label / section 列表反馈');
});
}
function assert_no_stale_outbound_tags(client_config) {
return assertNoStaleOutboundTagsHelper(client_config, routing_target_ctx.tag_map, (old_tag, final_tag) => {
reportError('error',
sprintf('rename 后仍残留旧 outbound tag 引用: %s -> %s', old_tag, final_tag),
'请检查新增 sing-box 字段是否需要纳入 outbound tag rename 规则');
});
}
/* Config helper end */
const config = {};
@@ -514,7 +783,8 @@ if (!isEmpty(main_node)) {
if (cfg.enabled !== '1')
return;
let outbound = get_outbound(cfg.outbound);
let outbound = get_outbound(cfg.outbound, 'block-out',
sprintf('DNS Server %s 的出站', cfg.label || cfg['.name']));
if (outbound === 'direct-out' && isEmpty(self_mark))
outbound = null;
@@ -567,7 +837,8 @@ if (!isEmpty(main_node)) {
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
rule_set_ip_cidr_accept_empty: strToBool(cfg.rule_set_ip_cidr_accept_empty),
invert: strToBool(cfg.invert),
outbound: get_outbound(cfg.outbound),
outbound: get_outbound(cfg.outbound, 'block-out',
sprintf('DNS 规则 %s 的出站匹配', cfg.label || cfg['.name'])),
action: cfg.action,
server: get_resolver(cfg.server),
strategy: cfg.domain_strategy,
@@ -621,7 +892,7 @@ if (match(proxy_mode, /redirect/))
sniff: true,
sniff_override_destination: strToBool(sniff_override)
});
if (match(proxy_mode, /tproxy/))
if (match(proxy_mode, /tproxy/) && !isEmpty(tproxy_port))
push(config.inbounds, {
type: 'tproxy',
tag: 'tproxy-in',
@@ -671,67 +942,87 @@ if (!isEmpty(main_node)) {
let urltest_nodes = [];
if (main_node === 'urltest') {
const main_urltest_nodes = uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [];
const main_urltest_nodes = expand_node_filter(
uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [],
null,
null,
'main_node',
false);
const main_urltest_interval = uci.get(uciconfig, ucimain, 'main_urltest_interval');
const main_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_urltest_tolerance');
push(config.outbounds, {
type: 'urltest',
tag: 'main-out',
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_urltest_interval),
tolerance: strToInt(main_urltest_tolerance),
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
});
if (length(main_urltest_nodes)) {
push(config.outbounds, {
type: 'urltest',
tag: 'main-out',
outbounds: map_outbound_tags(main_urltest_nodes),
interval: strToTime(main_urltest_interval),
tolerance: strToInt(main_urltest_tolerance),
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
});
} else {
reportError('warning',
'主节点 URLTest 列表没有可用节点,已在本次生成中临时回退为阻断出站。',
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主节点 URLTest 列表');
push_block_outbound(config, 'main-out');
}
urltest_nodes = main_urltest_nodes;
} else {
const main_node_cfg = uci.get_all(uciconfig, main_node) || {};
if (main_node_cfg.type === 'wireguard') {
push(config.endpoints, generate_endpoint(main_node_cfg));
config.endpoints[length(config.endpoints)-1].tag = 'main-out';
if (is_node_section(main_node)) {
push_node_outbound(config, main_node_cfg, 'main-out');
} else {
push(config.outbounds, generate_outbound(main_node_cfg));
config.outbounds[length(config.outbounds)-1].tag = 'main-out';
reportError('warning',
sprintf('主节点引用了已删除的节点:%s,已在本次生成中临时回退为阻断出站。', main_node),
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主节点');
push_block_outbound(config, 'main-out');
}
}
if (main_udp_node === 'urltest') {
const main_udp_urltest_nodes = uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [];
const main_udp_urltest_nodes = expand_node_filter(
uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [],
null,
null,
'main_udp_node',
false);
const main_udp_urltest_interval = uci.get(uciconfig, ucimain, 'main_udp_urltest_interval');
const main_udp_urltest_tolerance = uci.get(uciconfig, ucimain, 'main_udp_urltest_tolerance');
push(config.outbounds, {
type: 'urltest',
tag: 'main-udp-out',
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_udp_urltest_interval),
tolerance: strToInt(main_udp_urltest_tolerance),
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
});
if (length(main_udp_urltest_nodes)) {
push(config.outbounds, {
type: 'urltest',
tag: 'main-udp-out',
outbounds: map_outbound_tags(main_udp_urltest_nodes),
interval: strToTime(main_udp_urltest_interval),
tolerance: strToInt(main_udp_urltest_tolerance),
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
});
} else {
reportError('warning',
'主 UDP 节点 URLTest 列表没有可用节点,已在本次生成中临时回退为阻断出站。',
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主 UDP 节点 URLTest 列表');
push_block_outbound(config, 'main-udp-out');
}
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else if (dedicated_udp_node) {
const main_udp_node_cfg = uci.get_all(uciconfig, main_udp_node) || {};
if (main_udp_node_cfg.type === 'wireguard') {
push(config.endpoints, generate_endpoint(main_udp_node_cfg));
config.endpoints[length(config.endpoints)-1].tag = 'main-udp-out';
if (is_node_section(main_udp_node)) {
push_node_outbound(config, main_udp_node_cfg, 'main-udp-out');
} else {
push(config.outbounds, generate_outbound(main_udp_node_cfg));
config.outbounds[length(config.outbounds)-1].tag = 'main-udp-out';
reportError('warning',
sprintf('主 UDP 节点引用了已删除的节点:%s,已在本次生成中临时回退为阻断出站。', main_udp_node),
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 客户端,重新选择主 UDP 节点');
push_block_outbound(config, 'main-udp-out');
}
}
for (let i in urltest_nodes) {
const urltest_node = uci.get_all(uciconfig, i) || {};
if (urltest_node.type === 'wireguard') {
push(config.endpoints, generate_endpoint(urltest_node));
config.endpoints[length(config.endpoints)-1].tag = 'cfg-' + i + '-out';
} else {
push(config.outbounds, generate_outbound(urltest_node));
config.outbounds[length(config.outbounds)-1].tag = 'cfg-' + i + '-out';
}
push_node_outbound(config, urltest_node, node_outbound_tag(i));
}
} else if (!isEmpty(default_outbound)) {
let urltest_nodes = [],
let group_nodes = [],
routing_nodes = [];
uci.foreach(uciconfig, uciroutingnode, (cfg) => {
@@ -739,49 +1030,80 @@ if (!isEmpty(main_node)) {
return;
if (cfg.node === 'urltest') {
const owner = cfg.label || cfg['.name'];
const urltest_nodes = expand_node_filter(cfg.urltest_nodes, cfg.node_filter, cfg.node_filter_exclude, owner, false);
if (!length(urltest_nodes)) {
reportError('warning',
sprintf('路由节点 %s 没有可用节点,已在本次生成中临时回退为阻断出站。', owner),
'请手动选择节点,或调整节点正则/排除正则以命中可用节点');
push_block_outbound(config, node_outbound_tag(cfg));
return;
}
push(config.outbounds, {
type: 'urltest',
tag: 'cfg-' + cfg['.name'] + '-out',
outbounds: map(cfg.urltest_nodes, (k) => `cfg-${k}-out`),
tag: node_outbound_tag(cfg),
outbounds: map_outbound_tags(urltest_nodes),
url: cfg.urltest_url,
interval: strToTime(cfg.urltest_interval),
tolerance: strToInt(cfg.urltest_tolerance),
idle_timeout: strToTime(cfg.urltest_idle_timeout),
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
});
urltest_nodes = [...urltest_nodes, ...filter(cfg.urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else {
const outbound = uci.get_all(uciconfig, cfg.node) || {};
if (outbound.type === 'wireguard') {
push(config.endpoints, generate_endpoint(outbound));
config.endpoints[length(config.endpoints)-1].bind_interface = cfg.bind_interface;
config.endpoints[length(config.endpoints)-1].detour = get_outbound(cfg.outbound);
if (cfg.domain_resolver)
config.endpoints[length(config.endpoints)-1].domain_resolver = {
server: get_resolver(cfg.domain_resolver),
strategy: cfg.domain_strategy
};
} else {
push(config.outbounds, generate_outbound(outbound));
config.outbounds[length(config.outbounds)-1].bind_interface = cfg.bind_interface;
config.outbounds[length(config.outbounds)-1].detour = get_outbound(cfg.outbound);
if (cfg.domain_resolver)
config.outbounds[length(config.outbounds)-1].domain_resolver = {
server: get_resolver(cfg.domain_resolver),
strategy: cfg.domain_strategy
};
group_nodes = [...group_nodes, ...filter(urltest_nodes, (l) => !~index(group_nodes, l))];
} else if (cfg.node === 'selector') {
const owner = cfg.label || cfg['.name'];
const selector_nodes = expand_node_filter(cfg.selector_nodes, cfg.node_filter, cfg.node_filter_exclude, owner, true);
const selector_outbounds = get_valid_selector_outbounds(selector_nodes, owner, cfg['.name']);
if (!length(selector_outbounds)) {
reportError('warning',
sprintf('路由节点 %s 没有可用节点,已在本次生成中临时回退为阻断出站。', owner),
'请手动选择节点,或调整节点正则/排除正则以命中可用节点');
push_block_outbound(config, node_outbound_tag(cfg));
return;
}
push(routing_nodes, cfg.node);
let selector_default = get_routing_target_outbound(
cfg.selector_default,
sprintf('路由节点 %s 的默认 Selector 节点', owner));
if (!isEmpty(selector_default) && !~index(selector_outbounds, selector_default))
selector_default = null;
push(config.outbounds, {
type: 'selector',
tag: node_outbound_tag(cfg),
outbounds: selector_outbounds,
default: selector_default,
interrupt_exist_connections: strToBool(cfg.selector_interrupt_exist_connections)
});
group_nodes = [...group_nodes, ...filter(selector_nodes, (l) => !~index(group_nodes, l))];
} else {
const resolved = resolve_outbound_target(
cfg.node,
sprintf('路由节点 %s 的节点', cfg.label || cfg['.name']),
[]);
if (!resolved || resolved.fatal || resolved.type !== 'node') {
reportError('warning',
sprintf('路由节点 %s 的节点引用已失效,已在本次生成中临时回退为阻断出站。', cfg.label || cfg['.name']),
'请进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,重新选择有效节点');
push_block_outbound(config, node_outbound_tag(cfg));
return;
}
const outbound = uci.get_all(uciconfig, resolved.node_id) || {};
push_node_outbound(config, outbound, node_outbound_tag(resolved.node_id), cfg);
push(routing_nodes, resolved.node_id);
}
});
for (let i in filter(urltest_nodes, (l) => !~index(routing_nodes, l))) {
const urltest_node = uci.get_all(uciconfig, i) || {};
if (urltest_node.type === 'wireguard')
push(config.endpoints, generate_endpoint(urltest_node));
else
push(config.outbounds, generate_outbound(urltest_node));
}
for (let i in filter(group_nodes, (l) => !~index(routing_nodes, l)))
push_routing_target_outbound(config, i, routing_nodes);
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
if (cfg.enabled === '1' && cfg.action === 'route')
push_routing_target_outbound(config, cfg.outbound, routing_nodes);
});
}
if (isEmpty(config.endpoints))
@@ -926,7 +1248,8 @@ if (!isEmpty(main_node)) {
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
invert: strToBool(cfg.invert),
action: cfg.action,
outbound: get_outbound(cfg.outbound),
outbound: get_outbound(cfg.outbound, 'block-out',
sprintf('路由规则 %s 的出站', cfg.label || cfg['.name'])),
override_address: cfg.override_address,
override_port: strToInt(cfg.override_port),
udp_disable_domain_unmapping: strToBool(cfg.udp_disable_domain_unmapping),
@@ -938,7 +1261,7 @@ if (!isEmpty(main_node)) {
});
});
config.route.final = get_outbound(default_outbound);
config.route.final = get_outbound(default_outbound, 'block-out', '默认出站');
/* Rule set */
uci.foreach(uciconfig, uciruleset, (cfg) => {
@@ -951,7 +1274,8 @@ if (!isEmpty(main_node)) {
format: cfg.format,
path: cfg.path,
url: cfg.url,
download_detour: get_outbound(cfg.outbound),
download_detour: get_outbound(cfg.outbound, 'block-out',
sprintf('规则集 %s 的下载出站', cfg.label || cfg['.name'])),
update_interval: cfg.update_interval
});
});
@@ -959,17 +1283,44 @@ if (!isEmpty(main_node)) {
/* Routing rules end */
/* Experimental start */
if (routing_mode in ['bypass_mainland_china', 'custom']) {
config.experimental = {
cache_file: {
config.experimental = {};
if (routing_mode in ['bypass_mainland_china', 'custom'])
config.experimental.cache_file = {
enabled: true,
path: RUN_DIR + '/cache.db',
store_rdrc: strToBool(cache_file_store_rdrc),
rdrc_timeout: strToTime(cache_file_rdrc_timeout),
}
};
if (strToBool(clash_api_enabled))
config.experimental.clash_api = {
external_controller: clash_api_external_controller,
secret: clash_api_secret,
default_mode: clash_api_default_mode,
access_control_allow_origin: clash_api_allow_origin,
access_control_allow_private_network: strToBool(clash_api_allow_private_network)
};
}
if (isEmpty(config.experimental))
config.experimental = null;
/* Experimental end */
apply_outbound_tag_rename(config);
assert_unique_outbound_tags(config);
assert_no_stale_outbound_tags(config);
writeDiagnostics();
/*
* fatal 级配置错误不能用无效配置覆盖现有 sing-box-c.json,否则 init 脚本中的
* sing-box check 会失败并拖垮正在运行的代理。这里保留上一份有效配置,只报告
* 聚合错误并退出非零,让服务继续使用上一份有效配置。
*/
if (hasErrors()) {
warn('HomeProxy 配置验证发现以下问题:\n\n' + formatErrors());
warn('为避免用无效配置覆盖当前可用配置,本次未写入新配置;服务将继续使用上次的有效配置。请修复上述问题后重启服务。\n');
exit(1);
}
system('mkdir -p ' + RUN_DIR);
writefile(RUN_DIR + '/sing-box-c.json', sprintf('%.J\n', removeBlankAttrs(config)));
@@ -67,14 +67,36 @@ export function getTime(epoch) {
};
export function wGET(url, ua) {
const result = wGETResult(url, ua);
return result?.stdout;
};
export function wGETResult(url, ua) {
if (!url || type(url) !== 'string')
return null;
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
const output = executeCommand(`/usr/bin/wget -qO- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
return trim(output.stdout);
let last_result = null;
for (let extra in [ '', '-4' ]) {
const output = executeCommand(`/usr/bin/wget -q ${extra} -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
output.stdout = trim(output.stdout);
output.stderr = trim(output.stderr);
last_result = output;
if (output.exitcode === 0 && output.stdout)
return output;
}
if (last_result) {
last_result.stdout = trim(last_result.stdout);
last_result.stderr = trim(last_result.stderr);
}
return last_result;
};
/* Utilities end */
@@ -8,7 +8,14 @@
'use strict';
import { cursor } from 'uci';
import { isEmpty, parseURL, validation } from 'homeproxy';
import { executeCommand, isEmpty, parseURL, validation } from 'homeproxy';
import {
format_controller,
is_portable_bind_host,
local_ipv4_addresses,
normalize_local_controller_option,
parse_controller
} from '/etc/homeproxy/scripts/clash_api.uc';
const uci = cursor();
@@ -25,8 +32,37 @@ const uciinfra = 'infra',
ucirouting = 'routing',
uciroutingnode = 'routing_node',
uciroutingrule = 'routing_rule',
ucisubscription = 'subscription',
uciserver = 'server';
/* Generate a random 256-bit hex secret for the Clash API. Returns null when no
* usable randomness source is available, in which case the caller leaves the
* secret unset rather than installing a predictable value. */
function generateClashSecret() {
let secret = trim(executeCommand("head -c 32 /dev/urandom | hexdump -v -e '1/1 \"%02x\"'")?.stdout || '');
if (!match(secret, /^[a-f0-9]{64}$/))
secret = trim(executeCommand('tr -dc a-f0-9 < /dev/urandom | head -c 64')?.stdout || '');
return match(secret, /^[a-f0-9]{32,}$/) ? secret : null;
}
function normalizeLocalControllerOptions() {
let local_addresses = local_ipv4_addresses((command) => executeCommand(command)?.stdout || ''),
fallback_host = local_addresses[0];
if (isEmpty(fallback_host))
return;
/* clash_api_external_controller is intentionally pinned to loopback (see
* below) and must NOT be rewritten to the LAN address. Only the LAN-facing
* display proxy is bound to a reachable local address here. */
for (let item in [
[ 'clash_api_proxy_external_controller', 9091, true ]
])
normalize_local_controller_option(uci, uciconfig, ucimain,
local_addresses, fallback_host, item[0], item[1], item[2]);
}
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
@@ -75,6 +111,56 @@ if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
if (isEmpty(uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback')))
uci.set(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback', '0');
/* Clash API dashboard integration */
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_enabled')))
uci.set(uciconfig, ucimain, 'clash_api_enabled', '1');
/* Bind sing-box's own Clash API controller to loopback so it is never
* reachable directly from the LAN; dashboards reach it only through the
* filtering display proxy. Existing LAN-bound controllers are migrated to
* 127.0.0.1 once (port preserved); a later deliberate change is left alone. */
const clash_external = uci.get(uciconfig, ucimain, 'clash_api_external_controller');
if (isEmpty(clash_external)) {
uci.set(uciconfig, ucimain, 'clash_api_external_controller', '127.0.0.1:9090');
} else if (isEmpty(uci.get(uciconfig, ucimigration, 'clash_api_localhost'))) {
const clash_controller = parse_controller(clash_external, 9090);
if (!is_portable_bind_host(clash_controller.host))
uci.set(uciconfig, ucimain, 'clash_api_external_controller',
format_controller('127.0.0.1', clash_controller.port || 9090));
}
uci.set(uciconfig, ucimigration, 'clash_api_localhost', '1');
/* sing-box's Clash API performs NO authentication when the secret is empty,
* and the LAN-facing display proxy forwards to it, so anyone able to reach the
* proxy could otherwise control the proxy without credentials. Generate a
* random secret when none is configured. */
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_secret'))) {
const clash_secret = generateClashSecret();
if (!isEmpty(clash_secret))
uci.set(uciconfig, ucimain, 'clash_api_secret', clash_secret);
}
normalizeLocalControllerOptions();
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_default_mode')))
uci.set(uciconfig, ucimain, 'clash_api_default_mode', 'Rule');
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_origin'))) {
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubex.github.io');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://metacubexd.pages.dev');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'http://d.metacubex.one');
uci.add_list(uciconfig, ucimain, 'clash_api_allow_origin', 'https://yacd.metacubex.one');
}
if (isEmpty(uci.get(uciconfig, ucimain, 'clash_api_allow_private_network')))
uci.set(uciconfig, ucimain, 'clash_api_allow_private_network', '1');
if (isEmpty(uci.get(uciconfig, ucimain, 'metacubexd_url')))
uci.set(uciconfig, ucimain, 'metacubexd_url', 'https://metacubexd.pages.dev/#/overview');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
@@ -0,0 +1,235 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 节点正则资源限制 helper。
*/
'use strict';
const NODE_FILTER_MAX_PATTERN_LENGTH = 512;
const NODE_FILTER_MAX_RESULT_NODES = 500;
const NODE_FILTER_MAX_SCAN_NODES = 2000;
function isEmpty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function limits() {
return {
max_pattern_length: NODE_FILTER_MAX_PATTERN_LENGTH,
max_result_nodes: NODE_FILTER_MAX_RESULT_NODES,
max_scan_nodes: NODE_FILTER_MAX_SCAN_NODES
};
}
function nodeFilterGuard(pattern) {
let result = { result: true, limits: limits() };
if (isEmpty(pattern))
return result;
if (length(pattern) > NODE_FILTER_MAX_PATTERN_LENGTH)
return {
result: false,
error: sprintf('正则长度超过 %d 字符', NODE_FILTER_MAX_PATTERN_LENGTH),
limits: limits()
};
return result;
}
function normalizeNodeList(value) {
let nodes = [];
if (isEmpty(value))
return nodes;
if (type(value) !== 'array')
value = [ value ];
for (let node_id in value)
if (!isEmpty(node_id) && !~index(nodes, node_id))
push(nodes, node_id);
return nodes;
}
function compilePattern(pattern) {
let validated = nodeFilterGuard(pattern);
if (!validated.result)
return validated;
if (isEmpty(pattern))
return {
result: true,
pattern: null,
limits: validated.limits
};
try {
return {
result: true,
pattern: regexp(pattern),
limits: validated.limits
};
} catch(e) {
return {
result: false,
error: e.message || sprintf('%s', e),
limits: validated.limits
};
}
}
function previewResult(nodes, meta) {
return {
result: true,
nodes: nodes,
truncated: meta.truncated,
scan_truncated: meta.scan_truncated,
max_result_nodes: meta.max_result_nodes,
max_scan_nodes: meta.max_scan_nodes
};
}
function appendUniqueNode(nodes, node_id) {
if (!isEmpty(node_id) && !~index(nodes, node_id))
push(nodes, node_id);
}
function appendLimitedNode(nodes, node_id, meta) {
if (isEmpty(node_id) || ~index(nodes, node_id))
return;
if (length(nodes) < meta.max_result_nodes)
push(nodes, node_id);
else
meta.truncated = true;
}
function previewManualNodeAllowed(uci, config, node_id, node_mode) {
const section_type = uci.get(config, node_id);
if (section_type === 'node')
return true;
if (node_mode === 'selector') {
if (node_id in ['direct-out', 'block-out'])
return true;
if (section_type === 'routing_node' &&
uci.get(config, node_id, 'enabled') === '1' &&
!isEmpty(uci.get(config, node_id, 'node')))
return true;
}
return false;
}
function filterManualNodeAllowed(ctx, node_id) {
if (type(ctx.allow_manual_node) === 'function')
return !!ctx.allow_manual_node(node_id);
return true;
}
function manualNodeError(ctx, node_id) {
if (type(ctx.on_invalid_manual_node) === 'function')
ctx.on_invalid_manual_node(node_id);
}
function labelOfNode(cfg) {
return cfg.label || cfg['.name'];
}
function scanMatchedNodeIds(ctx, pattern, meta) {
let matched = [],
scanned = 0;
if (!pattern)
return matched;
ctx.uci.foreach(ctx.config, ctx.node_type || 'node', (cfg) => {
if (scanned >= meta.max_scan_nodes) {
meta.scan_truncated = true;
return;
}
scanned++;
if (match(labelOfNode(cfg), pattern))
push(matched, cfg['.name']);
});
return matched;
}
function applyExcludePattern(ctx, nodes, pattern, meta) {
let exclude_ids = {};
if (!pattern)
return nodes;
for (let node_id in scanMatchedNodeIds(ctx, pattern, meta))
exclude_ids[node_id] = true;
return filter(nodes, (node_id) => !exclude_ids[node_id]);
}
function defaultMeta() {
let guard = nodeFilterGuard(null),
guard_limits = guard.limits;
return {
truncated: false,
scan_truncated: false,
max_result_nodes: guard_limits.max_result_nodes,
max_scan_nodes: guard_limits.max_scan_nodes
};
}
export function node_filter_guard(pattern) {
return nodeFilterGuard(pattern);
};
export function node_filter_compile(pattern) {
return compilePattern(pattern);
};
export function expand_node_filter(ctx, manual_nodes, node_filter, node_filter_exclude) {
ctx = ctx || {};
let include_compiled = compilePattern(node_filter),
exclude_compiled = compilePattern(node_filter_exclude),
meta = defaultMeta(),
nodes = [];
if (!include_compiled.result)
return include_compiled;
if (!exclude_compiled.result)
return exclude_compiled;
for (let node_id in normalizeNodeList(manual_nodes)) {
if (filterManualNodeAllowed(ctx, node_id))
appendUniqueNode(nodes, node_id);
else
manualNodeError(ctx, node_id);
}
for (let node_id in scanMatchedNodeIds(ctx, include_compiled.pattern, meta))
appendLimitedNode(nodes, node_id, meta);
nodes = applyExcludePattern(ctx, nodes, exclude_compiled.pattern, meta);
return previewResult(nodes, meta);
};
export function preview_manual_node_allowed(uci, config, node_id, node_mode) {
return previewManualNodeAllowed(uci, config, node_id, node_mode);
};
export function normalize_node_list(value) {
return normalizeNodeList(value);
};
@@ -0,0 +1,362 @@
'use strict';
function isEmpty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function toArray(value) {
if (type(value) === 'array')
return value;
else if (isEmpty(value))
return [];
return [ value ];
}
function removeListValue(uci, config, section, option, value) {
let values = toArray(uci.get(config, section, option)),
filtered = [];
for (let item in values)
if (item !== value)
push(filtered, item);
if (length(filtered))
uci.set(config, section, option, filtered);
else if (length(values))
uci.delete(config, section, option);
}
function addReference(refs, entry) {
push(refs, entry);
}
function addCleanupChange(changes, node_id, scope, section, option, action, value) {
push(changes, {
node_id: node_id,
scope: scope,
section: section,
option: option,
action: action,
value: value
});
}
function normalizeUniqueNodeIds(node_ids) {
let seen = {},
unique_ids = [];
if (type(node_ids) !== 'array')
return unique_ids;
for (let node_id in node_ids)
if (!seen[node_id] && !isEmpty(node_id)) {
seen[node_id] = true;
push(unique_ids, node_id);
}
return unique_ids;
}
export function collect_node_references(uci, config, node_id) {
let refs = [];
if (isEmpty(node_id))
return refs;
if (uci.get(config, 'config', 'main_node') === node_id)
addReference(refs, {
scope: 'main_node',
section: 'config',
option: 'main_node',
label: 'Main node'
});
if (uci.get(config, 'config', 'main_udp_node') === node_id)
addReference(refs, {
scope: 'main_udp_node',
section: 'config',
option: 'main_udp_node',
label: 'Main UDP node'
});
if (~index(toArray(uci.get(config, 'config', 'main_urltest_nodes')), node_id))
addReference(refs, {
scope: 'main_urltest_nodes',
section: 'config',
option: 'main_urltest_nodes',
label: 'Main node / URLTest nodes'
});
if (~index(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes')), node_id))
addReference(refs, {
scope: 'main_udp_urltest_nodes',
section: 'config',
option: 'main_udp_urltest_nodes',
label: 'Main UDP node / URLTest nodes'
});
if (uci.get(config, 'routing', 'default_outbound') === node_id)
addReference(refs, {
scope: 'default_outbound',
section: 'routing',
option: 'default_outbound',
label: 'Routing / Default outbound'
});
uci.foreach(config, 'routing_node', (cfg) => {
const section = cfg['.name'],
label = cfg.label || section;
if (uci.get(config, section, 'node') === node_id)
addReference(refs, {
scope: 'routing_node_node',
section: section,
option: 'node',
label: sprintf('Routing Nodes / %s / Node', label)
});
if (uci.get(config, section, 'outbound') === node_id)
addReference(refs, {
scope: 'routing_node_outbound',
section: section,
option: 'outbound',
label: sprintf('Routing Nodes / %s / Outbound', label)
});
if (uci.get(config, section, 'selector_default') === node_id)
addReference(refs, {
scope: 'routing_node_selector_default',
section: section,
option: 'selector_default',
label: sprintf('Routing Nodes / %s / Default', label)
});
if (~index(toArray(uci.get(config, section, 'urltest_nodes')), node_id))
addReference(refs, {
scope: 'routing_node_urltest_nodes',
section: section,
option: 'urltest_nodes',
label: sprintf('Routing Nodes / %s / URLTest nodes', label)
});
if (~index(toArray(uci.get(config, section, 'selector_nodes')), node_id))
addReference(refs, {
scope: 'routing_node_selector_nodes',
section: section,
option: 'selector_nodes',
label: sprintf('Routing Nodes / %s / Selector nodes', label)
});
});
uci.foreach(config, 'routing_rule', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
addReference(refs, {
scope: 'routing_rule_outbound',
section: cfg['.name'],
option: 'outbound',
label: sprintf('Routing Rules / %s / Outbound', cfg.label || cfg['.name'])
});
});
uci.foreach(config, 'dns_server', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
addReference(refs, {
scope: 'dns_server_outbound',
section: cfg['.name'],
option: 'outbound',
label: sprintf('DNS Server / %s / Outbound', cfg.label || cfg['.name'])
});
});
uci.foreach(config, 'dns_rule', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
addReference(refs, {
scope: 'dns_rule_outbound',
section: cfg['.name'],
option: 'outbound',
label: sprintf('DNS Rules / %s / Outbound', cfg.label || cfg['.name'])
});
});
uci.foreach(config, 'ruleset', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id)
addReference(refs, {
scope: 'ruleset_outbound',
section: cfg['.name'],
option: 'outbound',
label: sprintf('Rule set / %s / Outbound', cfg.label || cfg['.name'])
});
});
return refs;
};
export function cleanup_node_references_for_delete(uci, config, node_id, opts) {
opts = opts || {};
if (isEmpty(node_id))
return { changed: false, changes: [] };
let changed = false,
changes = [],
main_node_fallback = isEmpty(opts.main_node_fallback) ? 'nil' : opts.main_node_fallback,
main_udp_fallback = isEmpty(opts.main_udp_fallback) ? 'same' : opts.main_udp_fallback;
if (uci.get(config, 'config', 'main_node') === node_id) {
uci.set(config, 'config', 'main_node', main_node_fallback);
addCleanupChange(changes, node_id, 'main_node', 'config', 'main_node', 'set', main_node_fallback);
changed = true;
}
if (uci.get(config, 'config', 'main_udp_node') === node_id) {
uci.set(config, 'config', 'main_udp_node', main_udp_fallback);
addCleanupChange(changes, node_id, 'main_udp_node', 'config', 'main_udp_node', 'set', main_udp_fallback);
changed = true;
}
let before_main_urltest = length(toArray(uci.get(config, 'config', 'main_urltest_nodes')));
removeListValue(uci, config, 'config', 'main_urltest_nodes', node_id);
if (length(toArray(uci.get(config, 'config', 'main_urltest_nodes'))) !== before_main_urltest) {
addCleanupChange(changes, node_id, 'main_urltest_nodes', 'config', 'main_urltest_nodes', 'remove', null);
changed = true;
}
if (uci.get(config, 'config', 'main_node') === 'urltest' &&
!length(toArray(uci.get(config, 'config', 'main_urltest_nodes')))) {
uci.set(config, 'config', 'main_node', main_node_fallback);
addCleanupChange(changes, node_id, 'main_node', 'config', 'main_node', 'set', main_node_fallback);
changed = true;
}
let before_main_udp_urltest = length(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes')));
removeListValue(uci, config, 'config', 'main_udp_urltest_nodes', node_id);
if (length(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes'))) !== before_main_udp_urltest) {
addCleanupChange(changes, node_id, 'main_udp_urltest_nodes', 'config', 'main_udp_urltest_nodes', 'remove', null);
changed = true;
}
if (uci.get(config, 'config', 'main_udp_node') === 'urltest' &&
!length(toArray(uci.get(config, 'config', 'main_udp_urltest_nodes')))) {
uci.set(config, 'config', 'main_udp_node', main_udp_fallback);
addCleanupChange(changes, node_id, 'main_udp_node', 'config', 'main_udp_node', 'set', main_udp_fallback);
changed = true;
}
if (uci.get(config, 'routing', 'default_outbound') === node_id) {
uci.set(config, 'routing', 'default_outbound', 'nil');
addCleanupChange(changes, node_id, 'default_outbound', 'routing', 'default_outbound', 'set', 'nil');
changed = true;
}
uci.foreach(config, 'routing_node', (cfg) => {
const section = cfg['.name'];
if (uci.get(config, section, 'node') === node_id) {
uci.delete(config, section, 'node');
addCleanupChange(changes, node_id, 'routing_node_node', section, 'node', 'delete', null);
changed = true;
}
if (uci.get(config, section, 'outbound') === node_id) {
uci.delete(config, section, 'outbound');
addCleanupChange(changes, node_id, 'routing_node_outbound', section, 'outbound', 'delete', null);
changed = true;
}
if (uci.get(config, section, 'selector_default') === node_id) {
uci.delete(config, section, 'selector_default');
addCleanupChange(changes, node_id, 'routing_node_selector_default', section, 'selector_default', 'delete', null);
changed = true;
}
let before_urltest = length(toArray(uci.get(config, section, 'urltest_nodes')));
removeListValue(uci, config, section, 'urltest_nodes', node_id);
if (length(toArray(uci.get(config, section, 'urltest_nodes'))) !== before_urltest) {
addCleanupChange(changes, node_id, 'routing_node_urltest_nodes', section, 'urltest_nodes', 'remove', null);
changed = true;
}
let before_selector = length(toArray(uci.get(config, section, 'selector_nodes')));
removeListValue(uci, config, section, 'selector_nodes', node_id);
if (length(toArray(uci.get(config, section, 'selector_nodes'))) !== before_selector) {
addCleanupChange(changes, node_id, 'routing_node_selector_nodes', section, 'selector_nodes', 'remove', null);
changed = true;
}
});
uci.foreach(config, 'routing_rule', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
uci.set(config, cfg['.name'], 'outbound', 'block-out');
addCleanupChange(changes, node_id, 'routing_rule_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
changed = true;
}
});
uci.foreach(config, 'dns_server', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
uci.set(config, cfg['.name'], 'outbound', 'block-out');
addCleanupChange(changes, node_id, 'dns_server_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
changed = true;
}
});
uci.foreach(config, 'dns_rule', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
uci.set(config, cfg['.name'], 'outbound', 'block-out');
addCleanupChange(changes, node_id, 'dns_rule_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
changed = true;
}
});
uci.foreach(config, 'ruleset', (cfg) => {
if (uci.get(config, cfg['.name'], 'outbound') === node_id) {
uci.set(config, cfg['.name'], 'outbound', 'block-out');
addCleanupChange(changes, node_id, 'ruleset_outbound', cfg['.name'], 'outbound', 'set', 'block-out');
changed = true;
}
});
return {
changed: changed,
changes: changes,
main_node_fallback: main_node_fallback,
main_udp_fallback: main_udp_fallback
};
};
export function remove_subscription_nodes(uci, config, node_ids, opts) {
opts = opts || {};
let unique_ids = normalizeUniqueNodeIds(node_ids),
removed = 0,
changed = false,
changes = [],
main_node_fallback = isEmpty(opts.main_node_fallback) ? 'nil' : opts.main_node_fallback,
main_udp_fallback = isEmpty(opts.main_udp_fallback) ? 'same' : opts.main_udp_fallback;
for (let node_id in unique_ids) {
let cfg = uci.get_all(config, node_id);
if (!cfg || cfg['.type'] !== 'node' || isEmpty(cfg.grouphash))
continue;
const cleanup = cleanup_node_references_for_delete(uci, config, node_id, {
main_node_fallback: main_node_fallback,
main_udp_fallback: main_udp_fallback
});
if (cleanup.changed) {
changed = true;
for (let item in cleanup.changes)
push(changes, item);
}
uci.delete(config, node_id);
removed++;
}
return {
changed: changed,
removed: removed,
changes: changes,
main_node_fallback: main_node_fallback,
main_udp_fallback: main_udp_fallback
};
};
@@ -0,0 +1,290 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy outbound tag mapping helpers.
*/
'use strict';
function reserve_tag(used_tags, tag) {
if (type(tag) === 'string' && tag !== '')
used_tags[tag] = true;
}
function sanitize_outbound_tag(label) {
if (type(label) !== 'string')
return '';
let tag = '';
for (let i = 0; i < length(label); i++) {
let ch = ord(label, i);
if (ch >= 32 && ch !== 127)
tag += substr(label, i, 1);
}
return tag;
}
function conflicts_outbound_tag(tag, used_tags) {
return tag in used_tags ||
match(tag, /^cfg-.+-out$/) ||
match(tag, /^cfg-.+-out-shadowtls$/) ||
match(tag, /^cfg-.+-dns$/) ||
match(tag, /^cfg-.+-rule$/) ||
match(tag, /-out-shadowtls$/);
}
function make_suffixed_outbound_tag(base, section, used_tags) {
let prefix_len = (length(section) < 6) ? length(section) : 6;
for (let i = prefix_len; i <= length(section); i++) {
let tag = base + ' #' + substr(section, 0, i);
if (!conflicts_outbound_tag(tag, used_tags))
return tag;
}
for (let n = 2; ; n++) {
let tag = base + ' #' + section + '-' + n;
if (!conflicts_outbound_tag(tag, used_tags))
return tag;
}
}
function sort_outbound_tag_items(items) {
for (let i = 0; i < length(items); i++) {
let min = i;
for (let j = i + 1; j < length(items); j++)
if (items[j].section < items[min].section)
min = j;
if (min !== i) {
let item = items[i];
items[i] = items[min];
items[min] = item;
}
}
}
function sectionFallbackTag(section) {
return 'cfg-' + section + '-out';
}
function shadowtlsFallbackTag(section) {
return sectionFallbackTag(section) + '-shadowtls';
}
function shadowtlsTagFromOutbound(tag) {
if (type(tag) !== 'string' || tag === '')
return tag;
if (match(tag, /-out$/))
return tag + '-shadowtls';
return tag + '-out-shadowtls';
}
function renameTags(value, rename, rename_string) {
let t = type(value);
if (t === 'string')
return (rename_string && value in rename) ? rename[value] : value;
if (t === 'object') {
for (let k in value) {
if (k in ['tag', 'outbound', 'outbounds', 'detour', 'default', 'final', 'download_detour'])
value[k] = renameTags(value[k], rename, true);
else
value[k] = renameTags(value[k], rename, false);
}
return value;
}
if (t === 'array') {
for (let i = 0; i < length(value); i++)
value[i] = renameTags(value[i], rename, rename_string);
return value;
}
return value;
}
function collectStaleTagReferences(value, rename, rename_string, refs) {
let t = type(value);
if (t === 'string') {
if (rename_string && value in rename && rename[value] !== value)
refs[value] = true;
return;
}
if (t === 'object') {
for (let k in value)
collectStaleTagReferences(value[k], rename,
k in ['tag', 'outbound', 'outbounds', 'detour', 'default', 'final', 'download_detour'],
refs);
return;
}
if (t === 'array')
for (let i in value)
collectStaleTagReferences(i, rename, rename_string, refs);
}
function buildTagRenameMap(tag_map) {
let rename = {};
if (type(tag_map) !== 'object')
return rename;
for (let section in tag_map) {
let old_tag = sectionFallbackTag(section),
final_tag = tag_map[section];
rename[old_tag] = final_tag;
rename[old_tag + '-shadowtls'] = shadowtlsTagFromOutbound(final_tag);
}
return rename;
}
function checkUniqueTags(entries, seen, on_duplicate) {
for (let entry in (entries || [])) {
let tag = (type(entry) === 'object') ? entry.tag : null;
if (tag === null)
continue;
if (seen[tag] && type(on_duplicate) === 'function')
on_duplicate(tag);
seen[tag] = true;
}
}
export function build_outbound_tag_map(uci) {
const uciconfig = 'homeproxy';
let items = [],
used_tags = {},
tag_map = {};
const reserved_tags = [
'GLOBAL', 'any',
'direct-out', 'block-out', 'main-out', 'main-udp-out',
'default-dns', 'system-dns', 'main-dns', 'china-dns',
'dns-in', 'mixed-in', 'redirect-in', 'tproxy-in', 'tun-in',
'direct-domain', 'proxy-domain', 'geoip-cn', 'geosite-cn', 'geosite-noncn',
// 旧隐藏测速组的保留占位,避免用户标签或旧配置与保留名冲突;不是运行时测速组。
'__homeproxy_delay_test__'
];
for (let tag in reserved_tags)
reserve_tag(used_tags, tag);
uci.foreach(uciconfig, 'dns_server', (cfg) => {
reserve_tag(used_tags, 'cfg-' + cfg['.name'] + '-dns');
});
uci.foreach(uciconfig, 'ruleset', (cfg) => {
reserve_tag(used_tags, 'cfg-' + cfg['.name'] + '-rule');
});
uci.foreach(uciconfig, 'node', (cfg) => {
push(items, {
section: cfg['.name'],
label: cfg.label
});
});
uci.foreach(uciconfig, 'routing_node', (cfg) => {
if (cfg.enabled === '1' && cfg.node in ['selector', 'urltest'])
push(items, {
section: cfg['.name'],
label: cfg.label
});
});
sort_outbound_tag_items(items);
for (let item in items) {
let section = item.section,
base = sanitize_outbound_tag(item.label),
final_tag;
if (base === '') {
final_tag = 'cfg-' + section + '-out';
reserve_tag(used_tags, final_tag);
tag_map[section] = final_tag;
continue;
}
final_tag = conflicts_outbound_tag(base, used_tags)
? make_suffixed_outbound_tag(base, section, used_tags)
: base;
reserve_tag(used_tags, final_tag);
tag_map[section] = final_tag;
}
return tag_map;
};
export function get_outbound_tag(tag_map, section) {
if (type(tag_map) === 'object' && section in tag_map)
return tag_map[section];
return sectionFallbackTag(section);
};
export function get_shadowtls_outbound_tag(tag_map, section) {
return shadowtlsTagFromOutbound(get_outbound_tag(tag_map, section));
};
export function get_fallback_outbound_tag(section) {
return sectionFallbackTag(section);
};
export function get_fallback_shadowtls_outbound_tag(section) {
return shadowtlsFallbackTag(section);
};
export function apply_outbound_tag_rename(client_config, tag_map) {
renameTags(client_config, buildTagRenameMap(tag_map), false);
return client_config;
};
export function assert_unique_outbound_tags(client_config, on_duplicate) {
let seen = {};
checkUniqueTags(client_config?.outbounds, seen, on_duplicate);
checkUniqueTags(client_config?.endpoints, seen, on_duplicate);
return true;
};
export function assert_no_stale_outbound_tags(client_config, tag_map, on_stale) {
let refs = {},
rename = buildTagRenameMap(tag_map);
collectStaleTagReferences(client_config, rename, false, refs);
for (let tag in keys(refs))
if (type(on_stale) === 'function')
on_stale(tag, rename[tag]);
return true;
};
export function normalize_outbound_target(target, tag_map) {
if (type(target) !== 'string' || target === '')
return target;
if (type(tag_map) === 'object')
for (let section in tag_map) {
let final_tag = tag_map[section];
if (target === final_tag || target === get_fallback_outbound_tag(section))
return section;
}
let matched = match(target, /^cfg-(.+)-out$/);
return matched ? matched[1] : target;
};
@@ -0,0 +1,249 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 路由目标解析 helper。
*/
'use strict';
import {
get_outbound_tag,
normalize_outbound_target as normalizeOutboundTargetValue
} from 'outbound_tag';
function isEmpty(value) {
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function toArray(value) {
if (type(value) === 'array')
return value;
else if (isEmpty(value))
return [];
return [ value ];
}
function report(ctx, level, message, suggestion) {
if (type(ctx.reportError) === 'function')
ctx.reportError(level, message, suggestion);
}
function sectionType(ctx, name) {
return ctx.uci.get_all(ctx.config, name)?.['.type'];
}
function outboundTag(ctx, section) {
return get_outbound_tag(ctx?.tag_map, section);
}
export function is_builtin_outbound(_ctx, target) {
return target in ['block-out', 'direct-out'];
};
export function is_node_section(ctx, target) {
return sectionType(ctx, target) === (ctx.node_type || 'node');
};
export function is_routing_node_section(ctx, target) {
const cfg = ctx.uci.get_all(ctx.config, target) || {};
return cfg['.type'] === (ctx.routing_node_type || 'routing_node') &&
cfg.enabled === '1' &&
!isEmpty(cfg.node);
};
function addRoutingNodeEdge(ctx, edges, target) {
target = normalizeOutboundTargetValue(target, ctx?.tag_map);
if (is_routing_node_section(ctx, target))
push(edges, target);
}
function routingNodeEdges(ctx, section) {
let cfg = ctx.uci.get_all(ctx.config, section) || {},
edges = [];
if (!is_routing_node_section(ctx, section))
return edges;
if (cfg.node === 'selector') {
for (let node_id in toArray(cfg.selector_nodes))
addRoutingNodeEdge(ctx, edges, node_id);
addRoutingNodeEdge(ctx, edges, cfg.selector_default);
} else if (cfg.node === 'urltest') {
for (let node_id in toArray(cfg.urltest_nodes))
addRoutingNodeEdge(ctx, edges, node_id);
} else {
addRoutingNodeEdge(ctx, edges, cfg.node);
}
addRoutingNodeEdge(ctx, edges, cfg.outbound);
return edges;
}
export function routing_node_has_path(ctx, start, target, seen) {
start = normalizeOutboundTargetValue(start, ctx?.tag_map);
target = normalizeOutboundTargetValue(target, ctx?.tag_map);
if (isEmpty(start) || isEmpty(target) || !is_routing_node_section(ctx, start))
return false;
if (start === target)
return true;
seen = seen || {};
if (seen[start])
return false;
seen[start] = true;
for (let edge in routingNodeEdges(ctx, start))
if (routing_node_has_path(ctx, edge, target, seen))
return true;
return false;
};
export function normalize_outbound_target(target, tag_map) {
return normalizeOutboundTargetValue(target, tag_map);
};
export function resolve_outbound_target(ctx, target, owner, seen_path) {
target = normalize_outbound_target(target, ctx?.tag_map);
if (isEmpty(target))
return null;
else if (is_builtin_outbound(ctx, target))
return {
type: 'builtin',
outbound: target,
target: target
};
else if (is_node_section(ctx, target))
return {
type: 'node',
outbound: outboundTag(ctx, target),
target: target,
node_id: target
};
if (!seen_path)
seen_path = [];
if (~index(seen_path, target)) {
report(ctx, 'error',
sprintf('路由节点配置错误:检测到循环引用\n循环路径: %s',
join(' -> ', [ ...seen_path, target ])),
'进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,检查以下节点的"出站"配置,移除循环引用');
return { fatal: true };
}
const max_depth = ctx.max_depth || 20;
if (length(seen_path) >= max_depth) {
report(ctx, 'error',
sprintf('路由节点配置错误:嵌套层级过深\n当前路径: %s\n最大允许层级: %d',
join(' -> ', [ ...seen_path, target ]),
max_depth),
'简化路由节点的嵌套结构,避免过多的 Selector 嵌套');
return { fatal: true };
}
if (!is_routing_node_section(ctx, target)) {
if (owner)
report(ctx, 'warning',
sprintf('%s 引用了已删除或无效的节点:%s,已在本次生成中跳过。', owner, join(' -> ', [ ...seen_path, target ])),
'请进入 LuCI 界面检查对应的节点或路由节点引用');
return null;
}
const routing_node = ctx.uci.get(ctx.config, target, 'node');
if (routing_node === 'urltest' || routing_node === 'selector') {
return {
type: routing_node,
outbound: outboundTag(ctx, target),
target: target,
section_id: target
};
}
return resolve_outbound_target(ctx, routing_node, owner, [ ...seen_path, target ]);
};
export function get_routing_target_outbound(ctx, target, owner) {
const resolved = resolve_outbound_target(ctx, target, owner, []);
if (!resolved || resolved.fatal)
return null;
return resolved.outbound;
};
export function get_valid_selector_outbounds(ctx, selector_nodes, owner, owner_section) {
let outbounds = [];
for (let node_id in selector_nodes) {
if (owner_section && routing_node_has_path(ctx, node_id, owner_section, {})) {
report(ctx, 'error',
sprintf('路由节点配置错误:Selector %s 与 %s 形成循环引用。', owner, node_id),
'进入 LuCI 界面 -> 服务 -> HomeProxy -> 路由节点,检查 Selector 节点列表、默认节点和上游出站配置');
continue;
}
const outbound = get_routing_target_outbound(
ctx,
node_id,
sprintf('路由节点 %s 的 Selector 节点', owner)
);
if (!isEmpty(outbound))
push(outbounds, outbound);
}
return outbounds;
};
export function collect_routing_target_dependencies(ctx, client_config, target, routing_nodes, push_node_outbound, outbound_tag) {
const resolved = resolve_outbound_target(ctx, target, null, []);
if (!resolved || resolved.fatal || resolved.type !== 'node' || ~index(routing_nodes, resolved.node_id))
return false;
const node = ctx.uci.get_all(ctx.config, resolved.node_id) || {};
push_node_outbound(client_config, node, outbound_tag(resolved.node_id));
push(routing_nodes, resolved.node_id);
return true;
};
export function get_outbound(ctx, cfg, fallback, owner) {
if (isEmpty(cfg))
return null;
if (type(cfg) === 'array') {
if ('any-out' in cfg)
return 'any';
let outbounds = [];
for (let i in cfg)
push(outbounds, get_outbound(ctx, i, fallback, owner));
return outbounds;
}
switch (cfg) {
case 'block-out':
case 'direct-out':
return cfg;
default:
const resolved = resolve_outbound_target(ctx, cfg, null, []);
if (!resolved || resolved.fatal) {
report(ctx, 'warning',
sprintf('%s引用了已删除或无效的节点:%s,已在本次生成中回退为 %s。', owner ? owner + ' ' : '出站', cfg, fallback || '空值'),
'请进入 LuCI 界面检查路由、DNS、规则集中的出站引用');
return fallback || null;
}
return resolved.outbound;
}
};
@@ -0,0 +1,219 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 订阅 parser helper。
*/
'use strict';
function is_empty(ctx, value) {
if (type(ctx?.isEmpty) === 'function')
return ctx.isEmpty(value);
return !value || value === 'nil' || (type(value) in ['array', 'object'] && length(value) === 0);
}
function validate(ctx, kind, value) {
return type(ctx?.validation) === 'function' ? ctx.validation(kind, value) : false;
}
function log(ctx, ...args) {
if (type(ctx?.log) === 'function')
ctx.log(...args);
}
function singFeatures(ctx) {
return ctx?.sing_features || {};
}
function applyTlsCertPinPolicy(ctx, config, label, pin) {
if (type(ctx?.apply_tls_cert_pin_policy) === 'function')
ctx.apply_tls_cert_pin_policy(config, label, pin);
}
function parse_surge_proxy(ctx, line) {
let config;
const eq = index(line, '=');
if (eq < 0)
return null;
const label = trim(substr(line, 0, eq));
const rhs = trim(substr(line, eq + 1));
if (is_empty(ctx, label) || is_empty(ctx, rhs))
return null;
const tokens = [];
let cur = '';
let in_quote = false;
for (let i = 0; i < length(rhs); i++) {
const ch = substr(rhs, i, 1);
if (ch == '"')
in_quote = !in_quote;
else if (ch == ',' && !in_quote) {
push(tokens, trim(cur));
cur = '';
} else
cur += ch;
}
if (length(trim(cur)))
push(tokens, trim(cur));
if (length(tokens) < 3)
return null;
const surge_type = tokens[0];
const address = tokens[1];
const port = tokens[2];
const opts = {};
let last_key = null;
for (let i = 3; i < length(tokens); i++) {
const t = tokens[i];
const k = index(t, '=');
if (k < 0) {
if (last_key == 'port-hopping')
opts[last_key] = opts[last_key] + ',' + t;
continue;
}
let key = lc(trim(substr(t, 0, k)));
let val = trim(substr(t, k + 1));
if (length(val) >= 2 && substr(val, 0, 1) == '"' && substr(val, length(val) - 1, 1) == '"')
val = substr(val, 1, length(val) - 2);
opts[key] = val;
last_key = key;
}
function bval(v) { return (v == 'true') ? '1' : '0'; }
switch (surge_type) {
case 'trojan':
config = {
label: label,
type: 'trojan',
address: address,
port: port,
password: opts.password,
tls: '1',
tls_sni: opts.sni,
tls_insecure: opts['skip-cert-verify'] ? bval(opts['skip-cert-verify']) : '0',
tls_alpn: opts.alpn ? split(opts.alpn, ',') : null
};
if (opts.ws == 'true') {
config.transport = 'ws';
config.ws_path = opts['ws-path'];
if (opts['ws-headers']) {
const hm = match(opts['ws-headers'], /[Hh]ost\s*:\s*"?([^",]+)/);
if (hm)
config.ws_host = hm[1];
}
}
break;
case 'ss':
config = {
label: label,
type: 'shadowsocks',
address: address,
port: port,
shadowsocks_encrypt_method: opts['encrypt-method'],
password: opts.password
};
if (opts['udp-over-tcp'] == 'true') {
config.udp_over_tcp = '1';
config.udp_over_tcp_version = '2';
}
if (opts['shadow-tls-password'] && opts['shadow-tls-sni']) {
config.shadowtls_enabled = '1';
config.shadowtls_address = address;
config.shadowtls_port = port;
config.shadowtls_password = opts['shadow-tls-password'];
config.shadowtls_sni = opts['shadow-tls-sni'];
config.shadowtls_version = opts['shadow-tls-version'] || '3';
}
break;
case 'hysteria2':
case 'hy2':
if (!singFeatures(ctx).with_quic) {
log(ctx, sprintf('Skipping unsupported %s node: %s.', surge_type, label));
log(ctx, sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: label,
type: 'hysteria2',
address: address,
port: port,
password: opts.password,
hysteria_obfs_type: opts.obfs,
hysteria_obfs_password: opts['salamander-password'] || opts['obfs-password'],
tls: '1',
tls_sni: opts.sni,
tls_insecure: opts['skip-cert-verify'] ? bval(opts['skip-cert-verify']) : '0'
};
applyTlsCertPinPolicy(ctx, config, label, opts['server-cert-fingerprint-sha256']);
if (opts['port-hopping'])
config.hysteria_hopping_port = map(split(opts['port-hopping'], ','),
(seg) => replace(trim(seg), '-', ':'));
if (opts.alpn)
config.tls_alpn = split(opts.alpn, ',');
break;
case 'anytls':
config = {
label: label,
type: 'anytls',
address: address,
port: port,
password: opts.password,
tls: '1',
tls_sni: opts.sni,
tls_insecure: opts['skip-cert-verify'] ? bval(opts['skip-cert-verify']) : '0'
};
break;
default:
log(ctx, sprintf('Skipping unsupported Surge proxy type: %s (%s).', surge_type, label));
return null;
}
if (!is_empty(ctx, config)) {
if (config.address)
config.address = replace(config.address, /\[|\]/g, '');
if (!validate(ctx, 'host', config.address) || !validate(ctx, 'port', config.port)) {
log(ctx, sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL'));
return null;
}
}
return config;
}
export function parse_surge_subscription(ctx, body) {
const lines = split(body, '\n');
const nodes = [];
let in_proxies = false;
for (let i = 0; i < length(lines); i++) {
const ln = trim(lines[i]);
if (match(lines[i], /^[ \t]*proxies\s*:/)) {
in_proxies = true;
continue;
}
if (in_proxies && match(lines[i], /^[A-Za-z0-9_-]+\s*:/) && !match(lines[i], /^\s/))
in_proxies = false;
if (!in_proxies)
continue;
if (is_empty(ctx, ln) || substr(ln, 0, 1) == '#')
continue;
if (index(ln, '=') < 0)
continue;
const cfg = parse_surge_proxy(ctx, ln);
if (cfg)
push(nodes, cfg);
}
return nodes;
};
@@ -8,15 +8,17 @@
'use strict';
import { md5 } from 'digest';
import { open } from 'fs';
import { open, writefile } from 'fs';
import { connect } from 'ubus';
import { cursor } from 'uci';
import { urldecode, urlencode } from 'luci.http';
import { init_action } from 'luci.sys';
import { remove_subscription_nodes } from '/etc/homeproxy/scripts/node_references.uc';
import { parse_surge_subscription } from '/etc/homeproxy/scripts/subscription_parsers.uc';
import {
wGET, decodeBase64Str, getTime, isEmpty, parseURL,
wGETResult, decodeBase64Str, getTime, isEmpty, parseURL,
validation, HP_DIR, RUN_DIR
} from 'homeproxy';
@@ -31,6 +33,7 @@ const ucimain = 'config',
ucisubscription = 'subscription';
const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0',
allow_unsupported_tls_pin_fallback = uci.get(uciconfig, ucisubscription, 'allow_unsupported_tls_pin_fallback') || '0',
filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled',
filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [],
packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp',
@@ -72,6 +75,10 @@ const ubus = connect();
const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {};
/* Common var end */
const SUBSCRIPTION_DIAGNOSTICS_PATH = RUN_DIR + '/subscription-diagnostics.json';
const SUBSCRIPTION_UPDATE_STATUS_PATH = RUN_DIR + '/subscription-update-status.json';
let subscription_diagnostics = [];
/* Log */
system(`mkdir -p ${RUN_DIR}`);
function log(...args) {
@@ -80,6 +87,135 @@ function log(...args) {
logfile.close();
}
function reportSubscriptionDiagnostic(type, message, suggestion) {
push(subscription_diagnostics, {
type,
source: 'subscription',
message,
suggestion
});
}
function writeSubscriptionDiagnostics() {
system(`mkdir -p ${RUN_DIR}`);
if (length(subscription_diagnostics)) {
writefile(SUBSCRIPTION_DIAGNOSTICS_PATH, sprintf('%.J\n', {
time: time(),
items: subscription_diagnostics
}));
} else {
system(`rm -f ${SUBSCRIPTION_DIAGNOSTICS_PATH}`);
}
}
function writeSubscriptionUpdateStatus(running, completed, update_result, error) {
system(`mkdir -p ${RUN_DIR}`);
writefile(SUBSCRIPTION_UPDATE_STATUS_PATH, sprintf('%.J\n', {
time: time(),
running: !!running,
completed: !!completed,
update_result,
error: error || null
}));
}
function sanitizeCommandError(text) {
text = trim(text || '');
if (isEmpty(text))
return null;
text = split(text, '\n')[0] || text;
return text;
}
function uci_value_equal(a, b) {
if (type(a) == 'array')
a = map(a, (v) => sprintf('%s', v));
else
a = sprintf('%s', a);
if (type(b) == 'array')
b = map(b, (v) => sprintf('%s', v));
else
b = sprintf('%s', b);
return sprintf('%J', a) == sprintf('%J', b);
}
function setOrDeleteList(section, option, values) {
if (length(values))
uci.set(uciconfig, section, option, values);
else
uci.delete(uciconfig, section, option);
}
function logCleanupChange(change) {
if (!change || !change.node_id)
return;
let target = sprintf('%s.%s', change.section || '-', change.option || '-'),
action = change.action || 'update',
value = (action === 'set') ? sprintf(' to %s', change.value) : '';
log(sprintf('Cleaned reference to deleted subscription node %s: %s %s%s.',
change.node_id, target, action, value));
}
function version_at_least(version, major, minor, patch) {
const m = match(version || '', /^([0-9]+)\.([0-9]+)\.([0-9]+)/);
if (!m)
return false;
const current = [ int(m[1]), int(m[2]), int(m[3]) ],
required = [ major, minor, patch ];
for (let i = 0; i < 3; i++) {
if (current[i] > required[i])
return true;
if (current[i] < required[i])
return false;
}
return true;
}
function tls_cert_pin_unsupported() {
return !version_at_least(sing_features.version, 1, 13, 0);
}
function apply_tls_cert_pin_policy(config, label, pin) {
if (!pin || !tls_cert_pin_unsupported())
return;
const node_label = label || config.label || 'NULL',
version = sing_features.version || 'unknown';
if (config.tls_insecure === '1')
return;
if (allow_unsupported_tls_pin_fallback === '1' || config.type === 'hysteria2') {
config.tls_insecure = '1';
log(sprintf('Node %s uses server certificate fingerprint, but sing-box %s cannot express it; enabling explicit TLS insecure compatibility fallback.',
node_label,
version));
reportSubscriptionDiagnostic('warning',
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256;已降级为 TLS insecure 继续兼容。', node_label, version),
(config.type === 'hysteria2')
? '这是延续旧行为的兼容回退;如需更严格校验,请升级到支持 certificate_public_key_sha256 的 sing-box'
: '若安全要求较高,请关闭“Allow unsupported certificate pin fallback”,让此类节点默认跳过');
return;
}
config.__skip_reason = sprintf('该节点使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', version);
reportSubscriptionDiagnostic('warning',
sprintf('订阅节点 %s 使用证书指纹,但当前 sing-box %s 不支持 certificate_public_key_sha256,已跳过。', node_label, version),
'升级到支持 certificate_public_key_sha256 的 sing-box,或显式开启兼容 fallback 后重新更新订阅');
log(sprintf('Skipping node %s: server certificate fingerprint is unsupported by sing-box %s.',
node_label,
version));
}
function parse_uri(uri) {
let config, url, params;
@@ -190,6 +326,7 @@ function parse_uri(uri) {
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
apply_tls_cert_pin_policy(config, config.label, params.pinSHA256);
break;
case 'socks':
@@ -474,23 +611,26 @@ function parse_uri(uri) {
}
function main() {
if (via_proxy !== '1') {
log('Stopping service...');
init_action('homeproxy', 'stop');
}
for (let url in subscription_urls) {
url = replace(url, /#.*$/, '');
const groupHash = md5(url);
node_cache[groupHash] = {};
const res = wGET(url, user_agent);
const fetch_result = wGETResult(url, user_agent) || {},
res = fetch_result.stdout;
if (isEmpty(res)) {
const reason = sanitizeCommandError(fetch_result.stderr);
log(sprintf('Failed to fetch resources from %s.', url));
if (!isEmpty(reason))
log(sprintf('Fetch stderr: %s', reason));
reportSubscriptionDiagnostic('error',
sprintf('订阅地址拉取失败:%s', url),
reason || '请检查路由器到订阅源的连通性、DNS 解析和 TLS 访问是否正常');
continue;
}
let nodes;
let nodes, parsed_as_surge = false;
try {
nodes = json(res).servers || json(res);
@@ -498,17 +638,41 @@ function main() {
if (nodes[0].server && nodes[0].method)
map(nodes, (_, i) => nodes[i].nodetype = 'sip008');
} catch(e) {
nodes = decodeBase64Str(res);
nodes = nodes ? split(trim(replace(nodes, / /g, '_')), '\n') : [];
/* 先识别 Surge/Clash 托管配置格式(YAML 风格),再回退 Base64。 */
if (match(res, /(^|\n)[ \t]*proxies\s*:\s*(#[^\n]*)?(\n|$)/)) {
nodes = parse_surge_subscription({
isEmpty,
validation,
sing_features,
log,
apply_tls_cert_pin_policy
}, res);
parsed_as_surge = true;
} else {
nodes = decodeBase64Str(res);
nodes = nodes ? split(trim(replace(nodes, / /g, '_')), '\n') : [];
}
}
let count = 0;
for (let node in nodes) {
let config;
if (!isEmpty(node))
config = parse_uri(node);
if (!isEmpty(node)) {
/* 信任边界来自控制流,而不是节点内容:只有 parse_surge_subscription()
* 分支产出的节点才会被当作已解析 config。任意 JSON 中伪造的
* 'nodetype'/'type' 不会绕过 parse_uri(),因为 JSON 路径下
* parsed_as_surge=false,仍然走原有校验和白名单映射。 */
if (parsed_as_surge && type(node) == 'object')
config = node;
else
config = parse_uri(node);
}
if (isEmpty(config))
continue;
if (!isEmpty(config.__skip_reason)) {
log(sprintf('Skipping node %s: %s', config.label || 'NULL', config.__skip_reason));
continue;
}
const label = config.label;
config.label = null;
@@ -543,17 +707,20 @@ function main() {
}
if (isEmpty(node_result)) {
log('Failed to update subscriptions: no valid node found.');
let fetch_errors = filter(subscription_diagnostics, (item) => item?.type === 'error' && item?.source === 'subscription');
if (via_proxy !== '1') {
log('Starting service...');
init_action('homeproxy', 'start');
}
log('Failed to update subscriptions: no valid node found.');
if (!length(fetch_errors))
reportSubscriptionDiagnostic('error',
'订阅更新失败:没有找到可用节点。',
'请检查订阅地址、过滤规则,以及是否有节点因当前 sing-box 不支持证书指纹而被跳过');
return false;
}
let added = 0, removed = 0;
let added = 0, updated = 0, removed = 0,
stale_nodes = [],
stale_labels = {};
uci.foreach(uciconfig, ucinode, (cfg) => {
/* Nodes created by the user */
if (!cfg.grouphash)
@@ -564,20 +731,59 @@ function main() {
return null;
if (!node_cache[cfg.grouphash] || !node_cache[cfg.grouphash][cfg['.name']]) {
uci.delete(uciconfig, cfg['.name']);
removed++;
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
push(stale_nodes, cfg['.name']);
stale_labels[cfg['.name']] = cfg.label || cfg['.name'];
return null;
} else {
const node = node_cache[cfg.grouphash][cfg['.name']];
let node_changed = false;
for (let v in node) {
if (isEmpty(node[v]))
continue;
if (!(v in cfg) || !uci_value_equal(cfg[v], node[v]))
node_changed = true;
uci.set(uciconfig, cfg['.name'], v, node[v]);
}
map(keys(cfg), (v) => {
if (v in node_cache[cfg.grouphash][cfg['.name']])
uci.set(uciconfig, cfg['.name'], v, node_cache[cfg.grouphash][cfg['.name']][v]);
else
if (substr(v, 0, 1) == '.')
return null;
if (!(v in node) || isEmpty(node[v])) {
uci.delete(uciconfig, cfg['.name'], v);
node_changed = true;
}
});
node_cache[cfg.grouphash][cfg['.name']].isExisting = true;
if (node_changed)
updated++;
node.isExisting = true;
}
});
if (length(stale_nodes)) {
const cleanup = remove_subscription_nodes(uci, uciconfig, stale_nodes);
removed += cleanup.removed;
for (let node_id in stale_nodes)
log(sprintf('Removing node: %s.', stale_labels[node_id] || node_id));
if (cleanup.changed) {
if (uci.get(uciconfig, ucimain, 'main_node') !== main_node)
log(sprintf('Deleted subscription node affected main node, falling back to %s.',
uci.get(uciconfig, ucimain, 'main_node') || 'nil'));
if (uci.get(uciconfig, ucimain, 'main_udp_node') !== main_udp_node)
log(sprintf('Deleted subscription node affected main UDP node, falling back to %s.',
uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'));
for (let change in cleanup.changes)
logCleanupChange(change);
}
}
for (let nodes in node_result)
map(nodes, (node) => {
if (node.isExisting)
@@ -589,50 +795,54 @@ function main() {
added++;
log(sprintf('Adding node: %s.', node.label));
});
});
uci.commit(uciconfig);
let need_restart = (via_proxy !== '1');
let need_restart = (via_proxy !== '1') || added > 0 || updated > 0 || removed > 0;
if (!isEmpty(main_node)) {
const first_server = uci.get_first(uciconfig, ucinode);
if (first_server) {
const has_server = !!uci.get_first(uciconfig, ucinode);
if (has_server) {
let main_urltest_nodes;
if (main_node === 'urltest') {
main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes'), (v) => {
main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes') || [], (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
setOrDeleteList(ucimain, 'main_urltest_nodes', main_urltest_nodes);
uci.commit(uciconfig);
}
if ((main_node === 'urltest') ? !length(main_urltest_nodes) : !uci.get(uciconfig, main_node)) {
uci.set(uciconfig, ucimain, 'main_node', first_server);
uci.set(uciconfig, ucimain, 'main_node', 'nil');
uci.commit(uciconfig);
need_restart = true;
log('Main node is gone, switching to the first node.');
log('Main node is gone, disabling main node.');
}
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
let main_udp_urltest_nodes;
if (main_udp_node === 'urltest') {
main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes'), (v) => {
main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes') || [], (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
setOrDeleteList(ucimain, 'main_udp_urltest_nodes', main_udp_urltest_nodes);
uci.commit(uciconfig);
}
if ((main_udp_node === 'urltest') ? !length(main_udp_urltest_nodes) : !uci.get(uciconfig, main_udp_node)) {
uci.set(uciconfig, ucimain, 'main_udp_node', first_server);
uci.set(uciconfig, ucimain, 'main_udp_node', 'same');
uci.commit(uciconfig);
need_restart = true;
log('Main UDP node is gone, switching to the first node.');
log('Main UDP node is gone, falling back to the main node.');
}
}
} else {
@@ -651,19 +861,37 @@ function main() {
init_action('homeproxy', 'start');
}
log(sprintf('%s nodes added, %s removed.', added, removed));
log(sprintf('%s nodes added, %s updated, %s removed.', added, updated, removed));
log('Successfully updated subscriptions.');
return true;
}
if (!isEmpty(subscription_urls))
if (!isEmpty(subscription_urls)) {
writeSubscriptionUpdateStatus(true, false, null, null);
try {
call(main);
const ok = call(main);
writeSubscriptionUpdateStatus(false, true, ok === false ? false : true,
(ok === false) ? (subscription_diagnostics[0]?.message || '订阅更新失败。') : null);
} catch(e) {
const status_error = sprintf('订阅更新异常:%s: %s', e.type || 'error', e.message || e);
log('[FATAL ERROR] An error occurred during updating subscriptions:');
log(sprintf('%s: %s', e.type, e.message));
log(e.stacktrace[0].context);
reportSubscriptionDiagnostic('error',
status_error,
'请查看 HomeProxy 日志中的 [SUBSCRIBE] 记录并修复订阅配置');
log('Restarting service...');
init_action('homeproxy', 'stop');
init_action('homeproxy', 'start');
writeSubscriptionUpdateStatus(false, true, false, status_error);
}
} else {
subscription_diagnostics = [];
writeSubscriptionUpdateStatus(false, true, false, '未配置订阅地址。');
}
writeSubscriptionDiagnostics();
+50 -15
View File
@@ -27,6 +27,24 @@ log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
}
delete_ip_rules() {
local mark="$1"
local table="$2"
while ip rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
:
done
}
delete_ip6_rules() {
local mark="$1"
local table="$2"
while ip -6 rule del fwmark "$mark" table "$table" 2>"/dev/null"; do
:
done
}
start_service() {
config_load "$CONF"
@@ -34,12 +52,10 @@ start_service() {
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
local outbound_node
if [ "$routing_mode" != "custom" ]; then
config_get outbound_node "config" "main_node" "nil"
else
config_get outbound_node "routing" "default_outbound" "nil"
fi
local outbound_node default_outbound
config_get outbound_node "config" "main_node" "nil"
config_get default_outbound "routing" "default_outbound" "nil"
[ "$outbound_node" != "nil" ] || outbound_node="$default_outbound"
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
@@ -111,18 +127,24 @@ start_service() {
config_get table_mark "infra" "table_mark" "100"
case "$proxy_mode" in
"redirect_tproxy")
local outbound_udp_node
local outbound_udp_node main_node_for_routing default_outbound_for_routing use_default_outbound_routing
config_get outbound_udp_node "config" "main_udp_node" "nil"
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
config_get main_node_for_routing "config" "main_node" "nil"
config_get default_outbound_for_routing "routing" "default_outbound" "nil"
[ "$routing_mode" != "custom" ] && [ "$main_node_for_routing" = "nil" ] && [ "$default_outbound_for_routing" != "nil" ] \
&& use_default_outbound_routing="1" || use_default_outbound_routing="0"
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ] || [ "$use_default_outbound_routing" = "1" ]; then
local tproxy_mark
config_get tproxy_mark "infra" "tproxy_mark" "101"
delete_ip_rules "$tproxy_mark" "$table_mark"
ip rule add fwmark "$tproxy_mark" table "$table_mark"
ip route add local 0.0.0.0/0 dev lo table "$table_mark"
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
if [ "$ipv6_support" -eq "1" ]; then
delete_ip6_rules "$tproxy_mark" "$table_mark"
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark"
ip -6 route add local ::/0 dev lo table "$table_mark"
ip -6 route replace local ::/0 dev lo table "$table_mark"
fi
fi
;;
@@ -131,14 +153,16 @@ start_service() {
config_get tun_name "infra" "tun_name" "singtun0"
config_get tun_mark "infra" "tun_mark" "102"
ip tuntap add mode tun user root name "$tun_name"
ip link show "$tun_name" >"/dev/null" 2>&1 || ip tuntap add mode tun user root name "$tun_name"
sleep 1s
ip link set "$tun_name" up
ip route replace default dev "$tun_name" table "$table_mark"
delete_ip_rules "$tun_mark" "$table_mark"
ip rule add fwmark "$tun_mark" lookup "$table_mark"
ip -6 route replace default dev "$tun_name" table "$table_mark"
delete_ip6_rules "$tun_mark" "$table_mark"
ip -6 rule add fwmark "$tun_mark" lookup "$table_mark"
;;
esac
@@ -173,6 +197,17 @@ start_service() {
procd_set_param respawn
procd_close_instance
# Clash API display proxy for dashboards.
local clash_api_enabled
config_get_bool clash_api_enabled "config" "clash_api_enabled" "0"
if [ "$clash_api_enabled" = "1" ]; then
procd_open_instance "clash-api-proxy"
procd_set_param command "/usr/bin/ucode" "$HP_DIR/scripts/clash_api_proxy.uc"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
fi
fi
if [ "$server_enabled" = "1" ]; then
@@ -267,17 +302,17 @@ stop_service() {
config_get tun_name "infra" "tun_name" "singtun0"
# Tproxy
ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
delete_ip_rules "$tproxy_mark" "$table_mark"
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
delete_ip6_rules "$tproxy_mark" "$table_mark"
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
# TUN
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
delete_ip_rules "$tun_mark" "$table_mark"
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
delete_ip6_rules "$tun_mark" "$table_mark"
# Nftables rules
for i in "homeproxy_dstnat_redir" "homeproxy_output_redir" \
@@ -34,6 +34,14 @@
"path": "homeproxy/server"
}
},
"admin/services/homeproxy/backup": {
"title": "Backup / Restore",
"order": 25,
"action": {
"type": "view",
"path": "homeproxy/backup"
}
},
"admin/services/homeproxy/status": {
"title": "Service Status",
"order": 30,
@@ -3,20 +3,58 @@
"description": "Grant access to homeproxy configuration",
"read": {
"file": {
"/etc/homeproxy/scripts/update_subscriptions.uc": [ "exec" ],
"/var/run/homeproxy/homeproxy.log": [ "read" ],
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
},
"ubus": {
"service": [ "list" ],
"luci.homeproxy": [ "*" ]
"luci.homeproxy": [
"acllist_read",
"connection_check",
"resources_get_version",
"singbox_get_features"
],
"luci.homeproxy_node_tools": [
"config_diagnostics",
"node_references",
"preview_node_filter",
"update_subscriptions_status",
"validate_regex"
]
},
"uci": [ "homeproxy" ]
},
"write": {
"file": {
"/tmp/homeproxy_certificate.tmp": [ "write" ]
"/tmp/homeproxy_certificate.tmp": [ "write" ],
"/tmp/homeproxy-backup-*.tar.gz": [ "read" ],
"/tmp/homeproxy-restore-*.tar.gz": [ "write" ]
},
"ubus": {
"luci.homeproxy": [
"acllist_read",
"acllist_write",
"certificate_write",
"log_clean",
"resources_update",
"singbox_generator"
],
"luci.homeproxy_backup": [
"backup_cleanup",
"backup_create",
"backup_get_upload_path",
"backup_restore",
"backup_validate"
],
"luci.homeproxy_node_tools": [
"remove_subscription_nodes",
"update_subscriptions"
],
"luci.homeproxy_tcping": [
"node_tcping",
"nodes_tcping"
]
},
"uci": [ "homeproxy" ]
}
@@ -163,7 +163,7 @@ const methods = {
const type = req.args?.type;
let result = {};
const fd = popen(`/usr/bin/sing-box generate ${type} ${shellquote(req.args?.params || '')}`);
const fd = popen('/usr/bin/sing-box generate ' + type + ' ' + shellquote(req.args?.params || ''));
if (fd) {
let ech_cfg_set = false;
let ech_key_set = false;
@@ -234,8 +234,13 @@ const methods = {
resources_get_version: {
args: { type: 'type' },
call: function(req) {
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
return { version: version, error: error() };
const allowed_types = [ 'china_ip4', 'china_ip6', 'china_list', 'gfw_list' ];
if (index(allowed_types, req.args?.type) === -1)
return { version: null, error: 'invalid resource type' };
const version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`) || '');
return { version, error: error() };
}
},
resources_update: {
@@ -0,0 +1,553 @@
#!/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 };
@@ -0,0 +1,270 @@
#!/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 };
@@ -0,0 +1,416 @@
#!/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 };