feat: link mac client to shared gateway proxy
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
94
src/server/sharedProxy.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user