#!/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();