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-клиент можно связать с серверным 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:
|
||||
|
||||
```bash
|
||||
@@ -361,6 +369,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| `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_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
|
||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||
@@ -386,6 +395,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| Метод | Путь | Описание |
|
||||
| --------- | ---------------------- | ------------------------------------ |
|
||||
| `GET` | `/api/state` | Полное состояние системы |
|
||||
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
|
||||
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@ function App() {
|
||||
});
|
||||
const [clientSettings, setClientSettings] = useState({
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
@@ -81,7 +82,7 @@ function App() {
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
}
|
||||
@@ -268,11 +269,19 @@ function App() {
|
||||
async function saveClientSettings(nextSettings) {
|
||||
return withBusy(null, async () => {
|
||||
const data = await api.clientSettings.save(nextSettings);
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
await loadState();
|
||||
}, { 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 ===
|
||||
function emptyRule() {
|
||||
return {
|
||||
@@ -418,6 +427,7 @@ function App() {
|
||||
setPendingTag={setPendingTag}
|
||||
clientSettings={clientSettings}
|
||||
onSaveClientSettings={saveClientSettings}
|
||||
onCheckSharedProxy={checkSharedProxy}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
|
||||
@@ -51,6 +51,11 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ clientSettings }),
|
||||
}),
|
||||
checkSharedProxy: (url) =>
|
||||
request("/api/client-settings/shared-proxy/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
|
||||
@@ -26,6 +26,7 @@ function CopyField({ label, value }) {
|
||||
|
||||
function ClientHero({ state, status, activeServer }) {
|
||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
|
||||
const cfg = {
|
||||
running: {
|
||||
title: 'Локальный proxy работает',
|
||||
@@ -58,7 +59,15 @@ function ClientHero({ state, status, activeServer }) {
|
||||
kind: 'neutral',
|
||||
},
|
||||
}[status] || {};
|
||||
const view = homeBypass
|
||||
const view = sharedProxy
|
||||
? {
|
||||
...cfg,
|
||||
title: 'Общий gateway proxy',
|
||||
hint: 'Локальный proxy отправляет трафик на серверный gateway',
|
||||
badge: 'Gateway',
|
||||
kind: 'success',
|
||||
}
|
||||
: homeBypass
|
||||
? {
|
||||
...cfg,
|
||||
title: 'Домашний режим: VPN выключен',
|
||||
@@ -83,7 +92,13 @@ function ClientHero({ state, status, activeServer }) {
|
||||
<div className="client-hero-meta">
|
||||
<div>
|
||||
<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>
|
||||
<small className="muted">Трафик</small>
|
||||
@@ -112,6 +127,7 @@ function ClientSetup({
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const canApply = selected && selected !== state?.selectedTag;
|
||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
|
||||
|
||||
return (
|
||||
<div className="card client-setup">
|
||||
@@ -157,7 +173,9 @@ function ClientSetup({
|
||||
</button>
|
||||
</div>
|
||||
<small className="field-hint">
|
||||
{homeBypass
|
||||
{sharedProxy
|
||||
? 'Включён общий gateway: локальный VPN-сервер не используется.'
|
||||
: homeBypass
|
||||
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
||||
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
||||
</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 }) {
|
||||
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
|
||||
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
||||
@@ -225,6 +297,7 @@ function ProxyCard({ state, settings, busy, onSave }) {
|
||||
|
||||
function HomeBypassCard({ state, settings, busy, onSave }) {
|
||||
const enabled = Boolean(settings?.homeBypassEnabled);
|
||||
const sharedProxy = Boolean(settings?.sharedProxyEnabled);
|
||||
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
||||
|
||||
return (
|
||||
@@ -247,7 +320,11 @@ function HomeBypassCard({ state, settings, busy, onSave }) {
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
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>
|
||||
</div>
|
||||
@@ -256,11 +333,16 @@ function HomeBypassCard({ state, settings, busy, onSave }) {
|
||||
|
||||
function ClientFlow({ state, activeServer }) {
|
||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||
const sharedProxy = Boolean(state?.clientSettings?.sharedProxyEnabled);
|
||||
const shared = state?.clientSettings?.sharedProxy;
|
||||
const steps = [
|
||||
{ label: 'Mac', value: 'приложения' },
|
||||
{ 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 (
|
||||
@@ -305,6 +387,7 @@ export function ClientOverviewPage({
|
||||
setPendingTag,
|
||||
clientSettings,
|
||||
onSaveClientSettings,
|
||||
onCheckSharedProxy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
onRestart,
|
||||
@@ -324,6 +407,12 @@ export function ClientOverviewPage({
|
||||
onFetchSubscription={onFetchSubscription}
|
||||
onApply={onApply}
|
||||
/>
|
||||
<SharedProxyCard
|
||||
settings={clientSettings}
|
||||
busy={busy}
|
||||
onCheck={onCheckSharedProxy}
|
||||
onSave={onSaveClientSettings}
|
||||
/>
|
||||
<div className="grid-2">
|
||||
<ProxyCard
|
||||
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", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
fs.writeFileSync(
|
||||
@@ -78,3 +96,35 @@ test("client mode uses selected proxy port from client settings", () => {
|
||||
{ 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