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

@@ -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` | Список серверов из кэша |

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" },
],

View File

@@ -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}

View File

@@ -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: {

View File

@@ -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}

View 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",
});
});

View File

@@ -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" },
]);
});