Add home bypass mode for the Mac client
This commit is contained in:
@@ -15,6 +15,8 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst
|
|||||||
- UI: `http://127.0.0.1:3456`
|
- UI: `http://127.0.0.1:3456`
|
||||||
- HTTP/SOCKS proxy: `127.0.0.1:8080`
|
- HTTP/SOCKS proxy: `127.0.0.1:8080`
|
||||||
|
|
||||||
|
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN.
|
||||||
|
|
||||||
Ручной запуск из checkout:
|
Ручной запуск из checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
43
src/server/clientSettings.js
Normal file
43
src/server/clientSettings.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { settings } from "./config.js";
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT_SETTINGS = {
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readJson(filePath, fallback) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, value) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClientSettings(input = {}) {
|
||||||
|
return {
|
||||||
|
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readClientSettings() {
|
||||||
|
return normalizeClientSettings({
|
||||||
|
...DEFAULT_CLIENT_SETTINGS,
|
||||||
|
...readJson(settings.clientSettingsPath, {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeClientSettings(input) {
|
||||||
|
const normalized = normalizeClientSettings({
|
||||||
|
...readClientSettings(),
|
||||||
|
...(input && typeof input === "object" ? input : {}),
|
||||||
|
});
|
||||||
|
writeJson(settings.clientSettingsPath, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const settings = {
|
|||||||
statePath: path.join(dataDir, "state.json"),
|
statePath: path.join(dataDir, "state.json"),
|
||||||
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||||
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
||||||
|
clientSettingsPath: path.join(dataDir, "client-settings.json"),
|
||||||
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"),
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
readDeviceProfiles,
|
readDeviceProfiles,
|
||||||
writeDeviceProfiles,
|
writeDeviceProfiles,
|
||||||
} from "./devices.js";
|
} from "./devices.js";
|
||||||
|
import {
|
||||||
|
readClientSettings,
|
||||||
|
writeClientSettings,
|
||||||
|
} from "./clientSettings.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";
|
||||||
|
|
||||||
@@ -602,6 +606,7 @@ function publicState() {
|
|||||||
proxyBindIp: settings.bindIp,
|
proxyBindIp: settings.bindIp,
|
||||||
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
|
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
|
||||||
routingRuDirect: settings.routingRuDirect,
|
routingRuDirect: settings.routingRuDirect,
|
||||||
|
clientSettings: readClientSettings(),
|
||||||
configExists: fs.existsSync(settings.configPath),
|
configExists: fs.existsSync(settings.configPath),
|
||||||
singboxRunning: Boolean(singboxProcess),
|
singboxRunning: Boolean(singboxProcess),
|
||||||
singboxStartedAt,
|
singboxStartedAt,
|
||||||
@@ -924,6 +929,33 @@ async function handleApi(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && req.url === "/api/client-settings") {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
clientSettings: readClientSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT" && req.url === "/api/client-settings") {
|
||||||
|
const body = await readBody(req);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
clientSettings,
|
||||||
|
singboxRunning: Boolean(singboxProcess),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && req.url === "/api/rule-sets") {
|
if (req.method === "GET" && req.url === "/api/rule-sets") {
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
normalizeCidr,
|
normalizeCidr,
|
||||||
readDeviceProfiles,
|
readDeviceProfiles,
|
||||||
} from "./devices.js";
|
} from "./devices.js";
|
||||||
|
import { readClientSettings } from "./clientSettings.js";
|
||||||
|
|
||||||
const PROXY_TYPES = new Set([
|
const PROXY_TYPES = new Set([
|
||||||
"vless",
|
"vless",
|
||||||
@@ -259,7 +260,11 @@ export function buildGatewayConfig(
|
|||||||
|
|
||||||
const customRuleSets = readCustomRuleSets();
|
const customRuleSets = readCustomRuleSets();
|
||||||
const clientMode = settings.appMode === "client";
|
const clientMode = settings.appMode === "client";
|
||||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }];
|
const clientSettings = clientMode ? readClientSettings() : null;
|
||||||
|
const clientOutbound = clientSettings?.homeBypassEnabled
|
||||||
|
? "direct"
|
||||||
|
: vpnOutbound.tag;
|
||||||
|
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||||
const inbounds = [
|
const inbounds = [
|
||||||
...(clientMode
|
...(clientMode
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ function App() {
|
|||||||
proxyDefaultMode: 'vpn',
|
proxyDefaultMode: 'vpn',
|
||||||
devices: [],
|
devices: [],
|
||||||
});
|
});
|
||||||
|
const [clientSettings, setClientSettings] = useState({
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
});
|
||||||
const [selectedTag, setSelectedTag] = useState('');
|
const [selectedTag, setSelectedTag] = useState('');
|
||||||
const [pendingTag, setPendingTag] = useState('');
|
const [pendingTag, setPendingTag] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -78,6 +81,7 @@ function App() {
|
|||||||
proxyDefaultMode: 'vpn',
|
proxyDefaultMode: 'vpn',
|
||||||
devices: data.devices || [],
|
devices: data.devices || [],
|
||||||
});
|
});
|
||||||
|
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
||||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||||
}
|
}
|
||||||
@@ -261,6 +265,14 @@ function App() {
|
|||||||
saveDevicesConfig(nextConfig);
|
saveDevicesConfig(nextConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveClientSettings(nextSettings) {
|
||||||
|
return withBusy(null, async () => {
|
||||||
|
const data = await api.clientSettings.save(nextSettings);
|
||||||
|
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
||||||
|
await loadState();
|
||||||
|
}, { quiet: true });
|
||||||
|
}
|
||||||
|
|
||||||
// === Rules CRUD ===
|
// === Rules CRUD ===
|
||||||
function emptyRule() {
|
function emptyRule() {
|
||||||
return {
|
return {
|
||||||
@@ -404,6 +416,8 @@ function App() {
|
|||||||
servers={servers}
|
servers={servers}
|
||||||
pendingTag={pendingTag}
|
pendingTag={pendingTag}
|
||||||
setPendingTag={setPendingTag}
|
setPendingTag={setPendingTag}
|
||||||
|
clientSettings={clientSettings}
|
||||||
|
onSaveClientSettings={saveClientSettings}
|
||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
onApply={applyServer}
|
onApply={applyServer}
|
||||||
onRestart={restartSingbox}
|
onRestart={restartSingbox}
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clientSettings: {
|
||||||
|
get: () => request("/api/client-settings"),
|
||||||
|
save: (clientSettings) =>
|
||||||
|
request("/api/client-settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ clientSettings }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
ruleSets: {
|
ruleSets: {
|
||||||
get: () => request("/api/rule-sets"),
|
get: () => request("/api/rule-sets"),
|
||||||
save: (ruleSets) =>
|
save: (ruleSets) =>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function CopyField({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClientHero({ state, status, activeServer }) {
|
function ClientHero({ state, status, activeServer }) {
|
||||||
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
const cfg = {
|
const cfg = {
|
||||||
running: {
|
running: {
|
||||||
title: 'Локальный proxy работает',
|
title: 'Локальный proxy работает',
|
||||||
@@ -57,6 +58,15 @@ function ClientHero({ state, status, activeServer }) {
|
|||||||
kind: 'neutral',
|
kind: 'neutral',
|
||||||
},
|
},
|
||||||
}[status] || {};
|
}[status] || {};
|
||||||
|
const view = homeBypass
|
||||||
|
? {
|
||||||
|
...cfg,
|
||||||
|
title: 'Домашний режим: VPN выключен',
|
||||||
|
hint: 'Локальный proxy работает напрямую',
|
||||||
|
badge: 'Напрямую',
|
||||||
|
kind: 'info',
|
||||||
|
}
|
||||||
|
: cfg;
|
||||||
|
|
||||||
const userInfo = state?.userInfo;
|
const userInfo = state?.userInfo;
|
||||||
const traffic = userInfo
|
const traffic = userInfo
|
||||||
@@ -66,14 +76,14 @@ function ClientHero({ state, status, activeServer }) {
|
|||||||
return (
|
return (
|
||||||
<section className="client-hero">
|
<section className="client-hero">
|
||||||
<div className="client-hero-main">
|
<div className="client-hero-main">
|
||||||
<span className={`badge ${cfg.kind}`}>{cfg.badge}</span>
|
<span className={`badge ${view.kind}`}>{view.badge}</span>
|
||||||
<h1>{cfg.title}</h1>
|
<h1>{view.title}</h1>
|
||||||
<p>{cfg.hint}</p>
|
<p>{view.hint}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="client-hero-meta">
|
<div className="client-hero-meta">
|
||||||
<div>
|
<div>
|
||||||
<small className="muted">Активный сервер</small>
|
<small className="muted">Активный сервер</small>
|
||||||
<strong>{activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<small className="muted">Трафик</small>
|
<small className="muted">Трафик</small>
|
||||||
@@ -101,6 +111,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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card client-setup">
|
<div className="card client-setup">
|
||||||
@@ -146,7 +157,9 @@ function ClientSetup({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small className="field-hint">
|
<small className="field-hint">
|
||||||
В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.
|
{homeBypass
|
||||||
|
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
||||||
|
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,12 +187,43 @@ function ProxyCard({ state }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HomeBypassCard({ settings, busy, onSave }) {
|
||||||
|
const enabled = Boolean(settings?.homeBypassEnabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Домашний режим</h2>
|
||||||
|
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
|
||||||
|
{enabled ? 'Напрямую' : 'Через VPN'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется.
|
||||||
|
</p>
|
||||||
|
<label className="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>Я дома</strong>
|
||||||
|
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClientFlow({ state, activeServer }) {
|
function ClientFlow({ state, activeServer }) {
|
||||||
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
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: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' },
|
{ label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' },
|
||||||
{ label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' },
|
{ label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -222,6 +266,8 @@ export function ClientOverviewPage({
|
|||||||
servers,
|
servers,
|
||||||
pendingTag,
|
pendingTag,
|
||||||
setPendingTag,
|
setPendingTag,
|
||||||
|
clientSettings,
|
||||||
|
onSaveClientSettings,
|
||||||
onFetchSubscription,
|
onFetchSubscription,
|
||||||
onApply,
|
onApply,
|
||||||
onRestart,
|
onRestart,
|
||||||
@@ -243,6 +289,9 @@ export function ClientOverviewPage({
|
|||||||
/>
|
/>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<ProxyCard state={state} />
|
<ProxyCard state={state} />
|
||||||
|
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
|
||||||
|
</div>
|
||||||
|
<div className="grid-2">
|
||||||
<ClientActions
|
<ClientActions
|
||||||
state={state}
|
state={state}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|||||||
@@ -888,6 +888,28 @@ code, .mono {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.switch-row {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.switch-row span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.switch-row input {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
.copy-field {
|
.copy-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -45,3 +45,17 @@ test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
|||||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("client home bypass routes the local proxy directly", () => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(process.env.DATA_DIR, "client-settings.json"),
|
||||||
|
JSON.stringify({ homeBypassEnabled: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||||
|
|
||||||
|
assert.deepEqual(config.route.rule_set, []);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "direct" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user