diff --git a/README.md b/README.md index 89026f5..44f1577 100644 --- a/README.md +++ b/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://: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:` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна. + Ручной запуск из checkout: ```bash @@ -361,6 +369,7 @@ UI доступен на `http://: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://:3456`. | Метод | Путь | Описание | | --------- | ---------------------- | ------------------------------------ | | `GET` | `/api/state` | Полное состояние системы | +| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy | | `POST` | `/api/subscription` | Загрузить подписку по URL | | `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) | | `GET` | `/api/servers` | Список серверов из кэша | diff --git a/src/server/clientSettings.js b/src/server/clientSettings.js index aa43166..eb248c4 100644 --- a/src/server/clientSettings.js +++ b/src/server/clientSettings.js @@ -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, }; } diff --git a/src/server/config.js b/src/server/config.js index 4886b27..d7438fd 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -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", diff --git a/src/server/index.js b/src/server/index.js index f535c15..0eeeccc 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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, diff --git a/src/server/sharedProxy.js b/src/server/sharedProxy.js new file mode 100644 index 0000000..3d43bf5 --- /dev/null +++ b/src/server/sharedProxy.js @@ -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, + }; +} diff --git a/src/server/singbox.js b/src/server/singbox.js index 0f951e2..1e6958d 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -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" }, ], diff --git a/src/web/App.jsx b/src/web/App.jsx index 7b08c55..6092b32 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -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} diff --git a/src/web/api.js b/src/web/api.js index a4342f1..ed653fb 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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: { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index afa8317..d1bf8b0 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -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 }) {
Активный сервер - {homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'} + + {sharedProxy + ? `${state?.clientSettings?.sharedProxy?.host}:${state?.clientSettings?.sharedProxy?.port}` + : homeBypass + ? 'Не используется дома' + : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'} +
Трафик @@ -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 (
@@ -157,7 +173,9 @@ function ClientSetup({
- {homeBypass + {sharedProxy + ? 'Включён общий gateway: локальный VPN-сервер не используется.' + : homeBypass ? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.' : 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'} @@ -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 ( +
+
+

Общий gateway

+ + {enabled ? 'Подключён' : 'Не выбран'} + +
+

+ Укажите адрес серверного UI. Mac-клиент проверит /api/shared-proxy и переключит локальный proxy на общий gateway. +

+
+ +
+ setDraftUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)} + /> + +
+
+ {sharedProxy && ( +
+ +
+ )} + {enabled && ( + + )} +
+ ); +} + 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, + })} />
@@ -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} /> +
{ + 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", + }); +}); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 815718c..566e2b9 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -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" }, + ]); +});