feat: improve macos client proxy setup
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 0s

This commit is contained in:
2026-05-19 16:31:33 +03:00
parent c6352d781f
commit 73488384e4
10 changed files with 230 additions and 23 deletions

View File

@@ -1,7 +1,8 @@
PORT=3456
APP_MODE=gateway
CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT=8080
CLIENT_PROXY_PORT_START=8080
CLIENT_PROXY_PORT_END=8090
BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.12.13
INSTALL_RUNTIME_DEPS=true

View File

@@ -13,9 +13,17 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst
После запуска:
- UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8080`
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `80808090`
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN.
Установщик интерактивно спросит proxy-порт. Для неинтерактивного запуска можно задать его заранее; тогда вопрос не появится:
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
```
После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности.
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
Ручной запуск из checkout:
@@ -343,7 +351,8 @@ UI доступен на `http://<gateway-ip>:3456`.
| ------------------- | -------------------- | -------------------------------------- |
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT` | `8080` | Host-порт proxy для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
| `PORT` | `3456` | Порт веб-интерфейса |
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |

View File

@@ -9,7 +9,9 @@ services:
environment:
APP_MODE: client
PORT: ${PORT:-3456}
PROXY_PORT: ${PROXY_PORT:-8080}
PROXY_PORT: ${CLIENT_PROXY_PORT_START:-8080}
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT_START:-8080}
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT_END:-8090}
PROXY_BIND_IP: 0.0.0.0
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
@@ -19,7 +21,7 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
ports:
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
- "127.0.0.1:${CLIENT_PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
- "127.0.0.1:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}"
volumes:
- vpn-proxy-client-data:/var/lib/vpn-proxy
- sing-box-client-cache:/var/lib/sing-box

View File

@@ -5,6 +5,8 @@ INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
BRANCH="${VPN_PROXY_BRANCH:-master}"
COMPOSE_FILE="docker-compose.client.yml"
DEFAULT_PROXY_PORT="8080"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
log() {
printf '[vpn-proxy-client] %s\n' "$*"
@@ -19,12 +21,98 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
is_valid_port() {
case "$1" in
''|*[!0-9]*) return 1 ;;
esac
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
}
ask_proxy_port() {
local value=""
if [ -n "$REQUESTED_PROXY_PORT" ]; then
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
fi
printf '%s\n' "$REQUESTED_PROXY_PORT"
return 0
fi
if [ -r /dev/tty ]; then
while true; do
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
IFS= read -r value </dev/tty || value=""
value="${value:-$DEFAULT_PROXY_PORT}"
if is_valid_port "$value"; then
printf '%s\n' "$value"
return 0
fi
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
done
fi
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
fi
printf '%s\n' "$DEFAULT_PROXY_PORT"
}
wait_for_client_ui() {
local ui_port="${UI_PORT:-3456}"
local ui_url="http://127.0.0.1:${ui_port}/api/state"
local attempt
for attempt in $(seq 1 30); do
if curl -fsS "$ui_url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
printf '[vpn-proxy-client] docker compose status:\n' >&2
docker compose -f "$COMPOSE_FILE" ps >&2 || true
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
die "client UI is not ready; see Docker status and logs above"
}
set_env_value() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
if [ -f .env ] && grep -q "^${key}=" .env; then
awk -v key="$key" -v value="$value" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print key "=" value; next }
{ print }
' .env > "$tmp"
else
[ -f .env ] && cat .env > "$tmp"
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv "$tmp" .env
}
get_env_value() {
local key="$1"
[ -f .env ] || return 0
awk -v key="$key" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
' .env
}
if [[ "$(uname -s)" != "Darwin" ]]; then
die "this installer is intended for macOS"
fi
need git
need docker
need curl
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
@@ -46,18 +134,35 @@ if [[ ! -f .env && -f .env.example ]]; then
cp .env.example .env
fi
PROXY_PORT="$(ask_proxy_port)"
PROXY_PORT_END="$((PROXY_PORT + 10))"
if [ "$PROXY_PORT_END" -gt 65535 ]; then
PROXY_PORT_END=65535
fi
UI_PORT="${CLIENT_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
UI_PORT="${UI_PORT:-3456}"
set_env_value APP_MODE client
set_env_value CLIENT_PROXY_PORT_START "$PROXY_PORT"
set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END"
set_env_value PROXY_PORT "$PROXY_PORT"
log "proxy port: 127.0.0.1:${PROXY_PORT} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})"
log "building and starting Docker client"
docker compose -f "$COMPOSE_FILE" up -d --build
wait_for_client_ui
cat <<'EOF'
cat <<EOF
VPN Proxy Client is running.
UI:
http://127.0.0.1:3456
http://127.0.0.1:${UI_PORT}
Proxy:
HTTP/SOCKS5 127.0.0.1:8080
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
UI can switch proxy port within the Docker-published ${PROXY_PORT}-${PROXY_PORT_END} range.
Useful commands:
cd ~/.vpn-proxy-client
@@ -66,9 +171,9 @@ Useful commands:
docker compose -f docker-compose.client.yml down
Optional macOS system proxy example:
networksetup -setwebproxy Wi-Fi 127.0.0.1 8080
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8080
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8080
networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
Disable later:
networksetup -setwebproxystate Wi-Fi off

View File

@@ -6,6 +6,24 @@ const DEFAULT_CLIENT_SETTINGS = {
homeBypassEnabled: false,
};
function normalizeProxyPort(value, fallback = settings.proxyPort) {
const parsed = Number.parseInt(value, 10);
const min = Number.isInteger(settings.clientProxyPortStart)
? settings.clientProxyPortStart
: settings.proxyPort;
const max = Number.isInteger(settings.clientProxyPortEnd)
? settings.clientProxyPortEnd
: min;
const fallbackPort =
Number.isInteger(fallback) && fallback >= min && fallback <= max
? fallback
: min;
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
return fallbackPort;
}
return parsed;
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
@@ -23,12 +41,14 @@ function writeJson(filePath, value) {
export function normalizeClientSettings(input = {}) {
return {
homeBypassEnabled: Boolean(input.homeBypassEnabled),
proxyPort: normalizeProxyPort(input.proxyPort),
};
}
export function readClientSettings() {
return normalizeClientSettings({
...DEFAULT_CLIENT_SETTINGS,
proxyPort: settings.proxyPort,
...readJson(settings.clientSettingsPath, {}),
});
}

View File

@@ -6,6 +6,8 @@ export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: Number(process.env.PORT || 3456),
proxyPort: Number(process.env.PROXY_PORT || 8080),
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir,

View File

@@ -598,15 +598,21 @@ function publicState() {
const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []);
const deviceProfiles = readDeviceProfiles();
const clientSettings = readClientSettings();
const { subscriptionUrl, ...rest } = state;
return {
mode: settings.appMode,
port: settings.port,
proxyPort: settings.proxyPort,
proxyPort:
settings.appMode === "client" ? clientSettings.proxyPort : settings.proxyPort,
clientProxyPortRange: {
start: settings.clientProxyPortStart,
end: settings.clientProxyPortEnd,
},
proxyBindIp: settings.bindIp,
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
routingRuDirect: settings.routingRuDirect,
clientSettings: readClientSettings(),
clientSettings,
configExists: fs.existsSync(settings.configPath),
singboxRunning: Boolean(singboxProcess),
singboxStartedAt,

View File

@@ -264,6 +264,7 @@ export function buildGatewayConfig(
const clientOutbound = clientSettings?.homeBypassEnabled
? "direct"
: vpnOutbound.tag;
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
const inbounds = [
...(clientMode
@@ -282,7 +283,7 @@ export function buildGatewayConfig(
type: "mixed",
tag: "mixed-in",
listen: settings.bindIp,
listen_port: settings.proxyPort,
listen_port: mixedProxyPort,
sniff: true,
set_system_proxy: false,
},

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { flagFor } from '../utils/country.js';
import { formatBytes, formatRelative } from '../utils/format.js';
@@ -166,8 +166,21 @@ function ClientSetup({
);
}
function ProxyCard({ state }) {
const port = state?.proxyPort || 8080;
function ProxyCard({ state, settings, busy, onSave }) {
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
const port = settings?.proxyPort || state?.proxyPort || 8080;
const [draftPort, setDraftPort] = useState(String(port));
useEffect(() => {
setDraftPort(String(port));
}, [port]);
const parsedDraftPort = Number.parseInt(draftPort, 10);
const portInvalid =
!Number.isInteger(parsedDraftPort) ||
parsedDraftPort < range.start ||
parsedDraftPort > range.end;
const portDirty = !portInvalid && parsedDraftPort !== port;
const urls = useMemo(() => ({
http: `http://127.0.0.1:${port}`,
socks: `socks5://127.0.0.1:${port}`,
@@ -179,6 +192,29 @@ function ProxyCard({ state }) {
<h2>Локальный proxy</h2>
<span className="badge info">127.0.0.1:{port}</span>
</div>
<div className="field" style={{ marginBottom: 12 }}>
<label className="field-label">Порт proxy</label>
<div className="subscription-input">
<input
className="input"
type="number"
min={range.start}
max={range.end}
value={draftPort}
onChange={(e) => setDraftPort(e.target.value)}
/>
<button
className="btn btn-secondary"
disabled={busy || !portDirty}
onClick={() => onSave({ ...settings, proxyPort: parsedDraftPort })}
>
Сохранить
</button>
</div>
<small className={portInvalid ? 'field-error' : 'field-hint'}>
Доступный диапазон: {range.start}{range.end}
</small>
</div>
<div className="copy-stack">
<CopyField label="HTTP / HTTPS" value={urls.http} />
<CopyField label="SOCKS5" value={urls.socks} />
@@ -187,8 +223,9 @@ function ProxyCard({ state }) {
);
}
function HomeBypassCard({ settings, busy, onSave }) {
function HomeBypassCard({ state, settings, busy, onSave }) {
const enabled = Boolean(settings?.homeBypassEnabled);
const port = settings?.proxyPort || state?.proxyPort || 8080;
return (
<div className="card">
@@ -199,7 +236,7 @@ function HomeBypassCard({ settings, busy, onSave }) {
</span>
</div>
<p className="muted">
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется.
Включайте дома: приложения продолжают использовать <code>127.0.0.1:{port}</code>, но VPN не используется.
</p>
<label className="switch-row">
<span>
@@ -288,8 +325,13 @@ export function ClientOverviewPage({
onApply={onApply}
/>
<div className="grid-2">
<ProxyCard state={state} />
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
<ProxyCard
state={state}
settings={clientSettings}
busy={busy}
onSave={onSaveClientSettings}
/>
<HomeBypassCard state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
</div>
<div className="grid-2">
<ClientActions

View File

@@ -11,6 +11,7 @@ process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
const { buildGatewayConfig } = await import(
`../../src/server/singbox.js?client-mode=${Date.now()}`
);
const clientSettingsPath = path.join(process.env.DATA_DIR, "client-settings.json");
const subscriptionConfig = {
outbounds: [
@@ -27,6 +28,7 @@ const subscriptionConfig = {
};
test("client mode exposes only the local mixed proxy inbound", () => {
fs.rmSync(clientSettingsPath, { force: true });
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual(
@@ -38,6 +40,7 @@ test("client mode exposes only the local mixed proxy inbound", () => {
});
test("client mode routes mixed proxy fallback to the selected VPN", () => {
fs.rmSync(clientSettingsPath, { force: true });
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual(config.route.rule_set, []);
@@ -47,8 +50,9 @@ test("client mode routes mixed proxy fallback to the selected VPN", () => {
});
test("client home bypass routes the local proxy directly", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync(
path.join(process.env.DATA_DIR, "client-settings.json"),
clientSettingsPath,
JSON.stringify({ homeBypassEnabled: true }),
);
@@ -59,3 +63,18 @@ test("client home bypass routes the local proxy directly", () => {
{ inbound: ["mixed-in"], outbound: "direct" },
]);
});
test("client mode uses selected proxy port from client settings", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync(
clientSettingsPath,
JSON.stringify({ proxyPort: 8085 }),
);
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.equal(config.inbounds[0].listen_port, 8085);
assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "test-vpn" },
]);
});