feat: link mac client to shared gateway proxy
This commit is contained in:
10
README.md
10
README.md
@@ -31,6 +31,14 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst
|
|||||||
|
|
||||||
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
|
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
|
||||||
|
|
||||||
|
Также Mac-клиент можно связать с серверным gateway. На gateway доступна ручка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET http://<gateway-ui-host>:3456/api/shared-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Если gateway запущен и его mixed proxy работает, ручка вернёт `available: true` и SOCKS5 endpoint общего proxy. В Mac UI укажите адрес gateway UI, например `http://192.168.50.111:3456`. Клиент проверит ручку и переключит локальный `127.0.0.1:<proxy-port>` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна.
|
||||||
|
|
||||||
Ручной запуск из checkout:
|
Ручной запуск из checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -361,6 +369,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
|||||||
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` |
|
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` |
|
||||||
| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
|
| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
|
||||||
| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
|
| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
|
||||||
|
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
|
||||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||||
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||||
@@ -386,6 +395,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
|||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
| --------- | ---------------------- | ------------------------------------ |
|
| --------- | ---------------------- | ------------------------------------ |
|
||||||
| `GET` | `/api/state` | Полное состояние системы |
|
| `GET` | `/api/state` | Полное состояние системы |
|
||||||
|
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
|
||||||
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||||
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||||
| `GET` | `/api/servers` | Список серверов из кэша |
|
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { settings } from "./config.js";
|
|||||||
|
|
||||||
const DEFAULT_CLIENT_SETTINGS = {
|
const DEFAULT_CLIENT_SETTINGS = {
|
||||||
homeBypassEnabled: false,
|
homeBypassEnabled: false,
|
||||||
|
sharedProxyEnabled: false,
|
||||||
|
sharedProxyControlUrl: "",
|
||||||
|
sharedProxy: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeProxyPort(value, fallback = settings.proxyPort) {
|
function normalizeProxyPort(value, fallback = settings.proxyPort) {
|
||||||
@@ -38,10 +41,45 @@ function writeJson(filePath, value) {
|
|||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
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 = {}) {
|
export function normalizeClientSettings(input = {}) {
|
||||||
|
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
|
||||||
|
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
|
||||||
return {
|
return {
|
||||||
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||||
proxyPort: normalizeProxyPort(input.proxyPort),
|
proxyPort: normalizeProxyPort(input.proxyPort),
|
||||||
|
sharedProxyEnabled,
|
||||||
|
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
|
||||||
|
sharedProxy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const settings = {
|
|||||||
devicesPath: path.join(dataDir, "devices.json"),
|
devicesPath: path.join(dataDir, "devices.json"),
|
||||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
hwidPath: path.join(dataDir, "hwid"),
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
readClientSettings,
|
readClientSettings,
|
||||||
writeClientSettings,
|
writeClientSettings,
|
||||||
} from "./clientSettings.js";
|
} from "./clientSettings.js";
|
||||||
|
import {
|
||||||
|
buildSharedProxyInfo,
|
||||||
|
checkSharedProxyGateway,
|
||||||
|
} from "./sharedProxy.js";
|
||||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||||
import { tcpPing, resolveHost } from "./ping.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) {
|
function handleLogsStream(req, res) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
@@ -756,6 +790,20 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, publicState());
|
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") {
|
if (req.method === "GET" && req.url === "/api/config") {
|
||||||
const config = readSingboxConfig();
|
const config = readSingboxConfig();
|
||||||
return sendJson(res, 200, { success: true, config });
|
return sendJson(res, 200, { success: true, config });
|
||||||
@@ -947,12 +995,48 @@ async function handleApi(req, res) {
|
|||||||
const clientSettings = writeClientSettings(body.clientSettings || body);
|
const clientSettings = writeClientSettings(body.clientSettings || body);
|
||||||
const prevState = readJson(settings.statePath, {});
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
|
||||||
if (
|
if (settings.appMode === "client") {
|
||||||
settings.appMode === "client" &&
|
if (clientSettings.sharedProxyEnabled) {
|
||||||
|
await applyClientSharedProxy();
|
||||||
|
} else if (clientSettings.homeBypassEnabled) {
|
||||||
|
await applyClientDirectProxy();
|
||||||
|
} else if (
|
||||||
prevState.selectedTag &&
|
prevState.selectedTag &&
|
||||||
readJson(settings.subscriptionCachePath, null)?.config
|
readJson(settings.subscriptionCachePath, null)?.config
|
||||||
) {
|
) {
|
||||||
await applySelectedServer(prevState.selectedTag);
|
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, {
|
return sendJson(res, 200, {
|
||||||
@@ -1348,6 +1432,14 @@ async function handleApi(req, res) {
|
|||||||
error: "selectedTag обязателен",
|
error: "selectedTag обязателен",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (settings.appMode === "client") {
|
||||||
|
writeClientSettings({
|
||||||
|
...readClientSettings(),
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
sharedProxyEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await applySelectedServer(selectedTag);
|
await applySelectedServer(selectedTag);
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
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,26 +242,54 @@ function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
|||||||
return rules;
|
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(
|
export function buildGatewayConfig(
|
||||||
subscriptionConfig,
|
subscriptionConfig,
|
||||||
selectedTag,
|
selectedTag,
|
||||||
{ bypassAll = false } = {},
|
{ 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 customRuleSets = readCustomRuleSets();
|
||||||
const clientMode = settings.appMode === "client";
|
const clientMode = settings.appMode === "client";
|
||||||
const clientSettings = clientMode ? readClientSettings() : null;
|
const clientSettings = clientMode ? readClientSettings() : null;
|
||||||
const clientOutbound = clientSettings?.homeBypassEnabled
|
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"
|
? "direct"
|
||||||
: vpnOutbound.tag;
|
: vpnOutbound.tag;
|
||||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||||
@@ -305,7 +333,7 @@ export function buildGatewayConfig(
|
|||||||
},
|
},
|
||||||
inbounds,
|
inbounds,
|
||||||
outbounds: [
|
outbounds: [
|
||||||
vpnOutbound,
|
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
|
||||||
{ type: "direct", tag: "direct" },
|
{ type: "direct", tag: "direct" },
|
||||||
{ type: "block", tag: "block" },
|
{ type: "block", tag: "block" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
const [clientSettings, setClientSettings] = useState({
|
const [clientSettings, setClientSettings] = useState({
|
||||||
homeBypassEnabled: false,
|
homeBypassEnabled: false,
|
||||||
|
sharedProxyEnabled: false,
|
||||||
});
|
});
|
||||||
const [selectedTag, setSelectedTag] = useState('');
|
const [selectedTag, setSelectedTag] = useState('');
|
||||||
const [pendingTag, setPendingTag] = useState('');
|
const [pendingTag, setPendingTag] = useState('');
|
||||||
@@ -81,7 +82,7 @@ function App() {
|
|||||||
proxyDefaultMode: 'vpn',
|
proxyDefaultMode: 'vpn',
|
||||||
devices: data.devices || [],
|
devices: data.devices || [],
|
||||||
});
|
});
|
||||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||||
}
|
}
|
||||||
@@ -268,11 +269,19 @@ function App() {
|
|||||||
async function saveClientSettings(nextSettings) {
|
async function saveClientSettings(nextSettings) {
|
||||||
return withBusy(null, async () => {
|
return withBusy(null, async () => {
|
||||||
const data = await api.clientSettings.save(nextSettings);
|
const data = await api.clientSettings.save(nextSettings);
|
||||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||||
await loadState();
|
await loadState();
|
||||||
}, { quiet: true });
|
}, { quiet: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkSharedProxy(url) {
|
||||||
|
return withBusy('Общий proxy подключён', async () => {
|
||||||
|
const data = await api.clientSettings.checkSharedProxy(url);
|
||||||
|
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||||
|
await loadState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// === Rules CRUD ===
|
// === Rules CRUD ===
|
||||||
function emptyRule() {
|
function emptyRule() {
|
||||||
return {
|
return {
|
||||||
@@ -418,6 +427,7 @@ function App() {
|
|||||||
setPendingTag={setPendingTag}
|
setPendingTag={setPendingTag}
|
||||||
clientSettings={clientSettings}
|
clientSettings={clientSettings}
|
||||||
onSaveClientSettings={saveClientSettings}
|
onSaveClientSettings={saveClientSettings}
|
||||||
|
onCheckSharedProxy={checkSharedProxy}
|
||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
onApply={applyServer}
|
onApply={applyServer}
|
||||||
onRestart={restartSingbox}
|
onRestart={restartSingbox}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ clientSettings }),
|
body: JSON.stringify({ clientSettings }),
|
||||||
}),
|
}),
|
||||||
|
checkSharedProxy: (url) =>
|
||||||
|
request("/api/client-settings/shared-proxy/check", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
ruleSets: {
|
ruleSets: {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function CopyField({ label, value }) {
|
|||||||
|
|
||||||
function ClientHero({ state, status, activeServer }) {
|
function ClientHero({ state, status, activeServer }) {
|
||||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
|
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
|
||||||
const cfg = {
|
const cfg = {
|
||||||
running: {
|
running: {
|
||||||
title: 'Локальный proxy работает',
|
title: 'Локальный proxy работает',
|
||||||
@@ -58,7 +59,15 @@ function ClientHero({ state, status, activeServer }) {
|
|||||||
kind: 'neutral',
|
kind: 'neutral',
|
||||||
},
|
},
|
||||||
}[status] || {};
|
}[status] || {};
|
||||||
const view = homeBypass
|
const view = sharedProxy
|
||||||
|
? {
|
||||||
|
...cfg,
|
||||||
|
title: 'Общий gateway proxy',
|
||||||
|
hint: 'Локальный proxy отправляет трафик на серверный gateway',
|
||||||
|
badge: 'Gateway',
|
||||||
|
kind: 'success',
|
||||||
|
}
|
||||||
|
: homeBypass
|
||||||
? {
|
? {
|
||||||
...cfg,
|
...cfg,
|
||||||
title: 'Домашний режим: VPN выключен',
|
title: 'Домашний режим: VPN выключен',
|
||||||
@@ -83,7 +92,13 @@ function ClientHero({ state, status, activeServer }) {
|
|||||||
<div className="client-hero-meta">
|
<div className="client-hero-meta">
|
||||||
<div>
|
<div>
|
||||||
<small className="muted">Активный сервер</small>
|
<small className="muted">Активный сервер</small>
|
||||||
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
<strong>
|
||||||
|
{sharedProxy
|
||||||
|
? `${state?.clientSettings?.sharedProxy?.host}:${state?.clientSettings?.sharedProxy?.port}`
|
||||||
|
: homeBypass
|
||||||
|
? 'Не используется дома'
|
||||||
|
: activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<small className="muted">Трафик</small>
|
<small className="muted">Трафик</small>
|
||||||
@@ -112,6 +127,7 @@ function ClientSetup({
|
|||||||
const selected = pendingTag || state?.selectedTag || '';
|
const selected = pendingTag || state?.selectedTag || '';
|
||||||
const canApply = selected && selected !== state?.selectedTag;
|
const canApply = selected && selected !== state?.selectedTag;
|
||||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
|
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card client-setup">
|
<div className="card client-setup">
|
||||||
@@ -157,7 +173,9 @@ function ClientSetup({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small className="field-hint">
|
<small className="field-hint">
|
||||||
{homeBypass
|
{sharedProxy
|
||||||
|
? 'Включён общий gateway: локальный VPN-сервер не используется.'
|
||||||
|
: homeBypass
|
||||||
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
||||||
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
||||||
</small>
|
</small>
|
||||||
@@ -166,6 +184,60 @@ function ClientSetup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SharedProxyCard({ settings, busy, onCheck, onSave }) {
|
||||||
|
const enabled = Boolean(settings?.sharedProxyEnabled);
|
||||||
|
const sharedProxy = settings?.sharedProxy;
|
||||||
|
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftUrl(settings?.sharedProxyControlUrl || '');
|
||||||
|
}, [settings?.sharedProxyControlUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Общий gateway</h2>
|
||||||
|
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>
|
||||||
|
{enabled ? 'Подключён' : 'Не выбран'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
Укажите адрес серверного UI. Mac-клиент проверит <code>/api/shared-proxy</code> и переключит локальный proxy на общий gateway.
|
||||||
|
</p>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Адрес gateway</label>
|
||||||
|
<div className="subscription-input">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="http://192.168.50.111:3456"
|
||||||
|
value={draftUrl}
|
||||||
|
onChange={(e) => setDraftUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" disabled={busy || !draftUrl} onClick={() => onCheck(draftUrl)}>
|
||||||
|
Проверить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sharedProxy && (
|
||||||
|
<div className="copy-stack" style={{ marginTop: 12 }}>
|
||||||
|
<CopyField label="Gateway SOCKS5" value={`socks5://${sharedProxy.host}:${sharedProxy.port}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enabled && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost sm"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSave({ ...settings, sharedProxyEnabled: false })}
|
||||||
|
>
|
||||||
|
Вернуться к локальному VPN
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProxyCard({ state, settings, busy, onSave }) {
|
function ProxyCard({ state, settings, busy, onSave }) {
|
||||||
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
|
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
|
||||||
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
||||||
@@ -225,6 +297,7 @@ function ProxyCard({ state, settings, busy, onSave }) {
|
|||||||
|
|
||||||
function HomeBypassCard({ state, settings, busy, onSave }) {
|
function HomeBypassCard({ state, settings, busy, onSave }) {
|
||||||
const enabled = Boolean(settings?.homeBypassEnabled);
|
const enabled = Boolean(settings?.homeBypassEnabled);
|
||||||
|
const sharedProxy = Boolean(settings?.sharedProxyEnabled);
|
||||||
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -247,7 +320,11 @@ function HomeBypassCard({ state, settings, busy, onSave }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
|
onChange={(e) => onSave({
|
||||||
|
...settings,
|
||||||
|
homeBypassEnabled: e.target.checked,
|
||||||
|
sharedProxyEnabled: e.target.checked ? false : sharedProxy,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,11 +333,16 @@ function HomeBypassCard({ state, settings, busy, onSave }) {
|
|||||||
|
|
||||||
function ClientFlow({ state, activeServer }) {
|
function ClientFlow({ state, activeServer }) {
|
||||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
|
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
|
||||||
|
const shared = state?.clientSettings?.sharedProxy;
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: 'Mac', value: 'приложения' },
|
{ label: 'Mac', value: 'приложения' },
|
||||||
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
||||||
{ label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' },
|
{
|
||||||
{ label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
|
label: sharedProxy ? 'Общий gateway' : homeBypass ? 'Домашняя сеть' : 'VPN-сервер',
|
||||||
|
value: sharedProxy ? `${shared?.host}:${shared?.port}` : homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран',
|
||||||
|
},
|
||||||
|
{ label: 'Интернет', value: state?.singboxRunning ? sharedProxy ? 'через gateway' : homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,6 +387,7 @@ export function ClientOverviewPage({
|
|||||||
setPendingTag,
|
setPendingTag,
|
||||||
clientSettings,
|
clientSettings,
|
||||||
onSaveClientSettings,
|
onSaveClientSettings,
|
||||||
|
onCheckSharedProxy,
|
||||||
onFetchSubscription,
|
onFetchSubscription,
|
||||||
onApply,
|
onApply,
|
||||||
onRestart,
|
onRestart,
|
||||||
@@ -324,6 +407,12 @@ export function ClientOverviewPage({
|
|||||||
onFetchSubscription={onFetchSubscription}
|
onFetchSubscription={onFetchSubscription}
|
||||||
onApply={onApply}
|
onApply={onApply}
|
||||||
/>
|
/>
|
||||||
|
<SharedProxyCard
|
||||||
|
settings={clientSettings}
|
||||||
|
busy={busy}
|
||||||
|
onCheck={onCheckSharedProxy}
|
||||||
|
onSave={onSaveClientSettings}
|
||||||
|
/>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
state={state}
|
state={state}
|
||||||
|
|||||||
55
test/server/shared-proxy.test.js
Normal file
55
test/server/shared-proxy.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildSharedProxyInfo,
|
||||||
|
checkSharedProxyGateway,
|
||||||
|
} = await import("../../src/server/sharedProxy.js");
|
||||||
|
|
||||||
|
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||||
|
const info = buildSharedProxyInfo({
|
||||||
|
appMode: "gateway",
|
||||||
|
proxyPort: 8080,
|
||||||
|
running: true,
|
||||||
|
hostHeader: "192.168.50.111:3456",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(info.available, true);
|
||||||
|
assert.deepEqual(info.proxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: "http://192.168.50.111:8080",
|
||||||
|
socksUrl: "socks5://192.168.50.111:8080",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client shared proxy check normalizes gateway response into settings patch", async () => {
|
||||||
|
const patch = await checkSharedProxyGateway(
|
||||||
|
"http://192.168.50.111:3456",
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
available: true,
|
||||||
|
proxy: {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(patch.sharedProxyEnabled, true);
|
||||||
|
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
|
||||||
|
assert.deepEqual(patch.sharedProxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,6 +64,24 @@ test("client home bypass routes the local proxy directly", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("client home bypass can build direct proxy without local VPN", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
clientSettingsPath,
|
||||||
|
JSON.stringify({ homeBypassEnabled: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
|
||||||
|
|
||||||
|
assert.deepEqual(config.outbounds, [
|
||||||
|
{ type: "direct", tag: "direct" },
|
||||||
|
{ type: "block", tag: "block" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "direct" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("client mode uses selected proxy port from client settings", () => {
|
test("client mode uses selected proxy port from client settings", () => {
|
||||||
fs.rmSync(clientSettingsPath, { force: true });
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -78,3 +96,35 @@ test("client mode uses selected proxy port from client settings", () => {
|
|||||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("client shared proxy mode routes local proxy to gateway socks outbound", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
clientSettingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
sharedProxyEnabled: true,
|
||||||
|
sharedProxy: {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
|
||||||
|
|
||||||
|
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]);
|
||||||
|
assert.deepEqual(
|
||||||
|
config.outbounds.find((outbound) => outbound.tag === "shared-proxy"),
|
||||||
|
{
|
||||||
|
type: "socks",
|
||||||
|
tag: "shared-proxy",
|
||||||
|
server: "192.168.50.111",
|
||||||
|
server_port: 8080,
|
||||||
|
version: "5",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "shared-proxy" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user