feat: link mac client to shared gateway proxy
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 11s
Build and Deploy Gateway / deploy (push) Successful in 0s

This commit is contained in:
2026-05-19 22:47:05 +03:00
parent f914c28bc5
commit 95edefa84f
11 changed files with 501 additions and 29 deletions

View File

@@ -4,6 +4,9 @@ import { settings } from "./config.js";
const DEFAULT_CLIENT_SETTINGS = {
homeBypassEnabled: false,
sharedProxyEnabled: false,
sharedProxyControlUrl: "",
sharedProxy: null,
};
function normalizeProxyPort(value, fallback = settings.proxyPort) {
@@ -38,10 +41,45 @@ function writeJson(filePath, value) {
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}
function normalizeUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
try {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) return "";
url.hash = "";
url.search = "";
return url.toString().replace(/\/$/, "");
} catch {
return "";
}
}
function normalizeSharedProxy(value) {
if (!value || typeof value !== "object") return null;
const host = String(value.host || "").trim();
const port = Number.parseInt(value.port, 10);
const protocol = value.protocol === "http" ? "http" : "socks5";
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return {
host,
port,
protocol,
checkedAt: value.checkedAt || null,
};
}
export function normalizeClientSettings(input = {}) {
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
return {
homeBypassEnabled: Boolean(input.homeBypassEnabled),
proxyPort: normalizeProxyPort(input.proxyPort),
sharedProxyEnabled,
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
sharedProxy,
};
}

View File

@@ -22,6 +22,7 @@ export const settings = {
devicesPath: path.join(dataDir, "devices.json"),
deviceRulesPath: path.join(dataDir, "device-rules.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
hwidPath: path.join(dataDir, "hwid"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",

View File

@@ -21,6 +21,10 @@ import {
readClientSettings,
writeClientSettings,
} from "./clientSettings.js";
import {
buildSharedProxyInfo,
checkSharedProxyGateway,
} from "./sharedProxy.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js";
@@ -722,6 +726,36 @@ async function applySelectedServer(selectedTag) {
});
}
async function applyClientSharedProxy() {
const clientSettings = readClientSettings();
if (!clientSettings.sharedProxyEnabled || !clientSettings.sharedProxy) {
return false;
}
const generated = buildGatewayConfig(
{ outbounds: [], customRules: [] },
"",
);
writeSingboxConfig(generated);
await startSingbox();
pushLog(
"info",
`Mac client uses shared gateway proxy ${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`,
);
return true;
}
async function applyClientDirectProxy() {
const generated = buildGatewayConfig(
{ outbounds: [], customRules: [] },
"",
);
writeSingboxConfig(generated);
await startSingbox();
pushLog("info", "Mac client routes local proxy directly");
return true;
}
function handleLogsStream(req, res) {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
@@ -756,6 +790,20 @@ async function handleApi(req, res) {
return sendJson(res, 200, publicState());
}
if (req.method === "GET" && req.url === "/api/shared-proxy") {
return sendJson(
res,
200,
buildSharedProxyInfo({
appMode: settings.appMode,
proxyPort: settings.proxyPort,
running: Boolean(singboxProcess),
hostHeader: req.headers.host,
sharedProxyHost: settings.sharedProxyHost,
}),
);
}
if (req.method === "GET" && req.url === "/api/config") {
const config = readSingboxConfig();
return sendJson(res, 200, { success: true, config });
@@ -947,12 +995,48 @@ async function handleApi(req, res) {
const clientSettings = writeClientSettings(body.clientSettings || body);
const prevState = readJson(settings.statePath, {});
if (
settings.appMode === "client" &&
prevState.selectedTag &&
readJson(settings.subscriptionCachePath, null)?.config
) {
await applySelectedServer(prevState.selectedTag);
if (settings.appMode === "client") {
if (clientSettings.sharedProxyEnabled) {
await applyClientSharedProxy();
} else if (clientSettings.homeBypassEnabled) {
await applyClientDirectProxy();
} else if (
prevState.selectedTag &&
readJson(settings.subscriptionCachePath, null)?.config
) {
await applySelectedServer(prevState.selectedTag);
} else {
await stopSingbox();
removeSingboxConfig();
}
}
return sendJson(res, 200, {
success: true,
clientSettings,
singboxRunning: Boolean(singboxProcess),
});
}
if (req.method === "POST" && req.url === "/api/client-settings/shared-proxy/check") {
const body = await readBody(req);
const url = String(body.url || "").trim();
if (!url) {
return sendJson(res, 400, {
success: false,
error: "Укажите адрес gateway",
});
}
const patch = await checkSharedProxyGateway(url);
const clientSettings = writeClientSettings({
...readClientSettings(),
...patch,
homeBypassEnabled: false,
});
if (settings.appMode === "client") {
await applyClientSharedProxy();
}
return sendJson(res, 200, {
@@ -1348,6 +1432,14 @@ async function handleApi(req, res) {
error: "selectedTag обязателен",
});
if (settings.appMode === "client") {
writeClientSettings({
...readClientSettings(),
homeBypassEnabled: false,
sharedProxyEnabled: false,
});
}
await applySelectedServer(selectedTag);
return sendJson(res, 200, {
success: true,

94
src/server/sharedProxy.js Normal file
View File

@@ -0,0 +1,94 @@
function normalizeControlUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
const url = new URL(withProtocol);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Gateway URL must use http or https");
}
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
return url.toString().replace(/\/$/, "");
}
function proxyHostFromHeader(hostHeader) {
const raw = String(hostHeader || "").trim();
if (!raw) return "";
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
return end > 0 ? raw.slice(1, end) : "";
}
return raw.split(":")[0];
}
function normalizeProxyInfo(proxy) {
if (!proxy || typeof proxy !== "object") return null;
const host = String(proxy.host || "").trim();
const port = Number.parseInt(proxy.port, 10);
const protocol = proxy.protocol === "http" ? "http" : "socks5";
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return { host, port, protocol };
}
export function buildSharedProxyInfo({
appMode,
proxyPort,
running,
hostHeader,
sharedProxyHost,
}) {
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
const port = Number.parseInt(proxyPort, 10);
const available =
appMode === "gateway" &&
Boolean(running) &&
host &&
Number.isInteger(port) &&
port > 0 &&
port <= 65535;
const proxy = available
? {
host,
port,
protocol: "socks5",
httpUrl: `http://${host}:${port}`,
socksUrl: `socks5://${host}:${port}`,
}
: null;
return {
success: true,
available,
mode: appMode,
proxy,
};
}
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
const baseUrl = normalizeControlUrl(controlUrl);
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
headers: { accept: "application/json" },
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `Gateway returned ${response.status}`);
}
if (!data.available) {
throw new Error("Gateway shared proxy is not available");
}
const sharedProxy = normalizeProxyInfo(data.proxy);
if (!sharedProxy) {
throw new Error("Gateway returned invalid shared proxy settings");
}
return {
sharedProxyEnabled: true,
sharedProxyControlUrl: baseUrl,
sharedProxy,
};
}

View File

@@ -242,28 +242,56 @@ function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
return rules;
}
function sharedProxyOutbound(sharedProxy) {
if (!sharedProxy?.host || !sharedProxy?.port) return null;
if (sharedProxy.protocol === "http") {
return {
type: "http",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
};
}
return {
type: "socks",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
version: "5",
};
}
export function buildGatewayConfig(
subscriptionConfig,
selectedTag,
{ bypassAll = false } = {},
) {
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
if (!selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
}
const vpnOutbound = clone(selectedOutbound);
if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const customRuleSets = readCustomRuleSets();
const clientMode = settings.appMode === "client";
const clientSettings = clientMode ? readClientSettings() : null;
const clientOutbound = clientSettings?.homeBypassEnabled
? "direct"
: vpnOutbound.tag;
const sharedOutbound =
clientMode && clientSettings?.sharedProxyEnabled
? sharedProxyOutbound(clientSettings.sharedProxy)
: null;
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
const selectedOutbound = sharedOutbound
? null
: findOutbound(subscriptionConfig, selectedTag);
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
}
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const clientOutbound = sharedOutbound
? sharedOutbound.tag
: clientSettings?.homeBypassEnabled
? "direct"
: vpnOutbound.tag;
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
const inbounds = [
@@ -305,7 +333,7 @@ export function buildGatewayConfig(
},
inbounds,
outbounds: [
vpnOutbound,
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
],