feat: improve macos client proxy setup
This commit is contained in:
@@ -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
|
||||
|
||||
15
README.md
15
README.md
@@ -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-диапазона `8080–8090`
|
||||
|
||||
В 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user