Add Mac client mode and simplify local proxy UI
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
PORT=3456
|
||||
APP_MODE=gateway
|
||||
CLIENT_UI_PORT=3456
|
||||
CLIENT_PROXY_PORT=8080
|
||||
BASE_IMAGE=debian:bookworm-slim
|
||||
SINGBOX_VERSION=1.12.13
|
||||
INSTALL_RUNTIME_DEPS=true
|
||||
|
||||
@@ -9,6 +9,8 @@ env:
|
||||
DEPLOY_PATH: /opt/vpn-proxy
|
||||
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||
SINGBOX_VERSION: 1.12.13
|
||||
|
||||
jobs:
|
||||
@@ -44,6 +46,8 @@ jobs:
|
||||
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now."
|
||||
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
|
||||
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||
./scripts/build-runtime-base.sh
|
||||
fi
|
||||
|
||||
54
Dockerfile.client
Normal file
54
Dockerfile.client
Normal file
@@ -0,0 +1,54 @@
|
||||
ARG NODE_BUILD_IMAGE=node:20-alpine
|
||||
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||
|
||||
FROM ${NODE_BUILD_IMAGE} AS web-build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY index.html vite.config.js ./
|
||||
COPY src/web ./src/web
|
||||
RUN npm run build
|
||||
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
ARG SINGBOX_VERSION=1.12.13
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) sb_arch="amd64" ;; \
|
||||
arm64) sb_arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||
chmod +x /usr/local/bin/sing-box; \
|
||||
rm -rf /tmp/sing-box*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=web-build /src/dist /app/dist
|
||||
COPY package.json /app/package.json
|
||||
COPY src/server /app/src/server
|
||||
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||
|
||||
RUN chmod +x /entrypoint.client.sh \
|
||||
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||
|
||||
ENV APP_MODE=client \
|
||||
PORT=3456 \
|
||||
PROXY_PORT=8080 \
|
||||
PROXY_BIND_IP=0.0.0.0 \
|
||||
DATA_DIR=/var/lib/vpn-proxy \
|
||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||
ROUTING_RU_DIRECT=true \
|
||||
LOG_LEVEL=info
|
||||
|
||||
EXPOSE 3456 8080
|
||||
|
||||
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||
@@ -1,6 +1,8 @@
|
||||
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
ARG SINGBOX_VERSION=1.12.13
|
||||
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
@@ -11,8 +13,26 @@ ARG no_proxy
|
||||
RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
||||
&& export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}" \
|
||||
&& export no_proxy="${no_proxy:-${NO_PROXY:-}}" \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs npm dumb-init \
|
||||
&& for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do \
|
||||
[ -f "$file" ] || continue; \
|
||||
sed -i \
|
||||
-e "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
"$file"; \
|
||||
done \
|
||||
&& apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=20 \
|
||||
-o Acquire::https::Timeout=20 \
|
||||
-o Acquire::ForceIPv4=true \
|
||||
update \
|
||||
&& apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=20 \
|
||||
-o Acquire::https::Timeout=20 \
|
||||
-o Acquire::ForceIPv4=true \
|
||||
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs npm dumb-init \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
|
||||
36
README.md
36
README.md
@@ -1,3 +1,36 @@
|
||||
# VPN Proxy
|
||||
|
||||
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||
|
||||
## macOS: локальный Docker-клиент
|
||||
|
||||
Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
|
||||
```
|
||||
|
||||
После запуска:
|
||||
|
||||
- UI: `http://127.0.0.1:3456`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8080`
|
||||
|
||||
Ручной запуск из checkout:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.client.yml up -d --build
|
||||
```
|
||||
|
||||
Перезапуск и логи:
|
||||
|
||||
```bash
|
||||
cd ~/.vpn-proxy-client
|
||||
docker compose -f docker-compose.client.yml logs -f
|
||||
docker compose -f docker-compose.client.yml restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# VPN Proxy Gateway
|
||||
|
||||
Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||
@@ -306,6 +339,9 @@ 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` |
|
||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||
|
||||
36
docker-compose.client.yml
Normal file
36
docker-compose.client.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
vpn-proxy-client:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.client
|
||||
args:
|
||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||
container_name: vpn-proxy-client
|
||||
environment:
|
||||
APP_MODE: client
|
||||
PORT: ${PORT:-3456}
|
||||
PROXY_PORT: ${PROXY_PORT:-8080}
|
||||
PROXY_BIND_IP: 0.0.0.0
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
|
||||
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
|
||||
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}"
|
||||
volumes:
|
||||
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||
- sing-box-client-cache:/var/lib/sing-box
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
vpn-proxy-client-data:
|
||||
sing-box-client-cache:
|
||||
74
docs/superpowers/plans/2026-05-19-macos-client.md
Normal file
74
docs/superpowers/plans/2026-05-19-macos-client.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# macOS Docker Client Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a macOS Docker client mode that exposes a local HTTP/SOCKS proxy on `127.0.0.1:8080` with a friendlier UI and a curl installer.
|
||||
|
||||
**Architecture:** Reuse the current Node API, React UI, subscription parser, sing-box process manager, and routing rule generator. Add `APP_MODE=client` so the same backend emits a proxy-only sing-box config without TProxy, and use a dedicated Dockerfile/compose path for Mac installation.
|
||||
|
||||
**Tech Stack:** Node.js ESM, React/Vite, sing-box, Docker Compose, POSIX shell, `node:test`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Client Mode Config Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `test/server/singbox-client-mode.test.js`
|
||||
- Modify: `package.json`
|
||||
- Modify: `src/server/config.js`
|
||||
- Modify: `src/server/singbox.js`
|
||||
- Modify: `src/server/index.js`
|
||||
|
||||
- [ ] Add `node:test` coverage that proves `APP_MODE=client` config has `mixed-in`, no `tproxy-in`, no transparent fallback, and a VPN proxy fallback.
|
||||
- [ ] Add `npm test` script.
|
||||
- [ ] Add `settings.appMode`.
|
||||
- [ ] Make `buildGatewayConfig()` conditionally emit client-only inbounds and route rules.
|
||||
- [ ] Expose `mode` and hide irrelevant tproxy fields in public state.
|
||||
|
||||
### Task 2: macOS Client Docker Runtime
|
||||
|
||||
**Files:**
|
||||
- Create: `entrypoint.client.sh`
|
||||
- Create: `Dockerfile.client`
|
||||
- Create: `docker-compose.client.yml`
|
||||
|
||||
- [ ] Add an entrypoint that starts only the Node control server.
|
||||
- [ ] Add a Dockerfile that builds the Vite frontend inside Docker and installs only client runtime dependencies plus sing-box.
|
||||
- [ ] Add compose with loopback-only port publishing for UI and proxy.
|
||||
|
||||
### Task 3: User-Friendly Client UI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/components/ClientOverviewPage.jsx`
|
||||
- Modify: `src/web/App.jsx`
|
||||
- Modify: `src/web/components/Sidebar.jsx`
|
||||
- Modify: `src/web/components/Topbar.jsx`
|
||||
- Modify: `src/web/components/StatusPane.jsx`
|
||||
- Modify: `src/web/components/RouteChecker.jsx`
|
||||
- Modify: `src/web/styles.css`
|
||||
|
||||
- [ ] Add a client overview with status, active server, copyable proxy URLs, and macOS setup commands.
|
||||
- [ ] Hide gateway-only navigation and side status pane in client mode.
|
||||
- [ ] Rename topbar brand to match current mode.
|
||||
- [ ] Keep servers, logs, and settings reachable in client mode.
|
||||
|
||||
### Task 4: curl Installer and Docs
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/install-macos-client.sh`
|
||||
- Modify: `README.md`
|
||||
- Modify: `.env.example`
|
||||
|
||||
- [ ] Add curl-friendly installer with Docker/Git checks and update-or-clone behavior.
|
||||
- [ ] Document one-line install command and manual compose command.
|
||||
- [ ] Add client mode environment examples.
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Commands:**
|
||||
- `npm test`
|
||||
- `npm run build`
|
||||
- `docker compose -f docker-compose.client.yml config`
|
||||
|
||||
- [ ] Run all commands and fix any failures.
|
||||
- [ ] Inspect the diff to confirm existing CI/runtime-base edits remain untouched.
|
||||
48
docs/superpowers/specs/2026-05-19-macos-client-design.md
Normal file
48
docs/superpowers/specs/2026-05-19-macos-client-design.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# macOS Docker Client Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a simple macOS-friendly Docker client that behaves like the previous local proxy product: the user runs one container, opens a web UI, loads a subscription, chooses a server, and points macOS apps at `127.0.0.1:8080`.
|
||||
|
||||
## Product Shape
|
||||
|
||||
The client is not a transparent gateway. It must not require router changes, host networking, `NET_ADMIN`, iptables, ipset, or TProxy. The first-screen UI should explain the current proxy state, active server, and exact local proxy addresses. Gateway-only controls remain available only when the app runs in gateway mode.
|
||||
|
||||
## Runtime Architecture
|
||||
|
||||
`APP_MODE=client` switches the config generator to proxy-only sing-box config:
|
||||
|
||||
- one `mixed` inbound on `PROXY_PORT`;
|
||||
- no `tproxy` inbound;
|
||||
- custom routing rules still apply before fallback;
|
||||
- `proxyDefaultMode` controls the mixed proxy fallback and defaults to VPN;
|
||||
- generated configs still pass `sing-box check` before restart.
|
||||
|
||||
The client Docker image builds the React frontend inside Docker so macOS installation does not require local Node.js. Docker publishes only loopback ports:
|
||||
|
||||
- `127.0.0.1:3456` for the UI;
|
||||
- `127.0.0.1:8080` for HTTP/SOCKS proxy.
|
||||
|
||||
## Installer
|
||||
|
||||
The macOS installer is a curl-friendly shell script. It checks macOS, Docker, Docker Compose, and Git, clones or updates the repository under `~/.vpn-proxy-client`, then runs the client compose file with `--build`. It prints the UI URL, proxy URLs, and optional `networksetup` commands, but does not change system proxy settings automatically.
|
||||
|
||||
## UI
|
||||
|
||||
Client mode gets a user-facing overview based on the old workflow:
|
||||
|
||||
- status: ready, stopped, not configured, applying, error;
|
||||
- active server and traffic quota;
|
||||
- copyable HTTP and SOCKS5 proxy URLs;
|
||||
- short macOS setup commands;
|
||||
- primary actions: load subscription, choose server, restart, stop.
|
||||
|
||||
Gateway terminology such as TProxy, devices, router, transparent fallback, and direct bypass cache is hidden in client mode.
|
||||
|
||||
## Verification
|
||||
|
||||
Use `node:test` for server config behavior, then run:
|
||||
|
||||
- `npm test`;
|
||||
- `npm run build`;
|
||||
- `docker compose -f docker-compose.client.yml config`.
|
||||
21
entrypoint.client.sh
Executable file
21
entrypoint.client.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-3456}"
|
||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||
|
||||
log() {
|
||||
printf '[client-entrypoint] %s\n' "$*"
|
||||
}
|
||||
|
||||
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
|
||||
|
||||
export APP_MODE=client
|
||||
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
||||
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||
|
||||
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||
exec node /app/src/server/index.js
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"test": "node --test",
|
||||
"start": "node src/server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,18 +4,24 @@ set -euo pipefail
|
||||
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||
HTTPS_PROXY="${HTTPS_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTPS Proxy:/ {print $2; exit}')}"
|
||||
NO_PROXY="${NO_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/No Proxy:/ {print $2; exit}')}"
|
||||
|
||||
echo "Building runtime base: ${RUNTIME_BASE_IMAGE}"
|
||||
echo "Source base image: ${BASE_IMAGE}"
|
||||
echo "APT mirror: ${APT_MIRROR}"
|
||||
echo "APT security mirror: ${APT_SECURITY_MIRROR}"
|
||||
if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
||||
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||
|
||||
docker build \
|
||||
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||
--build-arg APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR}" \
|
||||
--build-arg HTTP_PROXY="${HTTP_PROXY}" \
|
||||
--build-arg HTTPS_PROXY="${HTTPS_PROXY}" \
|
||||
--build-arg NO_PROXY="${NO_PROXY}" \
|
||||
|
||||
78
scripts/install-macos-client.sh
Executable file
78
scripts/install-macos-client.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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"
|
||||
|
||||
log() {
|
||||
printf '[vpn-proxy-client] %s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[vpn-proxy-client] error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
need() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||
}
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
die "this installer is intended for macOS"
|
||||
fi
|
||||
|
||||
need git
|
||||
need docker
|
||||
|
||||
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"
|
||||
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
log "updating $INSTALL_DIR"
|
||||
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||
else
|
||||
log "cloning $REPO_URL#$BRANCH to $INSTALL_DIR"
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
if [[ ! -f .env && -f .env.example ]]; then
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
log "building and starting Docker client"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
VPN Proxy Client is running.
|
||||
|
||||
UI:
|
||||
http://127.0.0.1:3456
|
||||
|
||||
Proxy:
|
||||
HTTP/SOCKS5 127.0.0.1:8080
|
||||
|
||||
Useful commands:
|
||||
cd ~/.vpn-proxy-client
|
||||
docker compose -f docker-compose.client.yml logs -f
|
||||
docker compose -f docker-compose.client.yml restart
|
||||
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
|
||||
|
||||
Disable later:
|
||||
networksetup -setwebproxystate Wi-Fi off
|
||||
networksetup -setsecurewebproxystate Wi-Fi off
|
||||
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||
|
||||
EOF
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||
|
||||
export const settings = {
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
port: Number(process.env.PORT || 3456),
|
||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||
|
||||
@@ -596,11 +596,11 @@ function publicState() {
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const { subscriptionUrl, ...rest } = state;
|
||||
return {
|
||||
mode: "gateway",
|
||||
mode: settings.appMode,
|
||||
port: settings.port,
|
||||
proxyPort: settings.proxyPort,
|
||||
proxyBindIp: settings.bindIp,
|
||||
tproxyPort: settings.tproxyPort,
|
||||
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
|
||||
routingRuDirect: settings.routingRuDirect,
|
||||
configExists: fs.existsSync(settings.configPath),
|
||||
singboxRunning: Boolean(singboxProcess),
|
||||
|
||||
@@ -202,7 +202,7 @@ function ruDirectRule() {
|
||||
};
|
||||
}
|
||||
|
||||
function routeRules(customRules, vpnTag) {
|
||||
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const rules = [
|
||||
{
|
||||
@@ -217,8 +217,10 @@ function routeRules(customRules, vpnTag) {
|
||||
const ruRule = ruDirectRule();
|
||||
if (ruRule) rules.push(ruRule);
|
||||
|
||||
// Device defaults are only transparent-gateway fallbacks after global rules.
|
||||
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
|
||||
if (includeTransparent) {
|
||||
// Device defaults are only transparent-gateway fallbacks after global rules.
|
||||
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
|
||||
}
|
||||
|
||||
const proxyFallback = inboundDefaultRule(
|
||||
MIXED_INBOUND,
|
||||
@@ -227,12 +229,14 @@ function routeRules(customRules, vpnTag) {
|
||||
);
|
||||
if (proxyFallback) rules.push(proxyFallback);
|
||||
|
||||
const transparentFallback = inboundDefaultRule(
|
||||
TPROXY_INBOUND,
|
||||
deviceProfiles.defaultTransparentMode,
|
||||
vpnTag,
|
||||
);
|
||||
if (transparentFallback) rules.push(transparentFallback);
|
||||
if (includeTransparent) {
|
||||
const transparentFallback = inboundDefaultRule(
|
||||
TPROXY_INBOUND,
|
||||
deviceProfiles.defaultTransparentMode,
|
||||
vpnTag,
|
||||
);
|
||||
if (transparentFallback) rules.push(transparentFallback);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
@@ -254,6 +258,30 @@ export function buildGatewayConfig(
|
||||
}
|
||||
|
||||
const customRuleSets = readCustomRuleSets();
|
||||
const clientMode = settings.appMode === "client";
|
||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }];
|
||||
const inbounds = [
|
||||
...(clientMode
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "tproxy",
|
||||
tag: "tproxy-in",
|
||||
listen: "::",
|
||||
listen_port: settings.tproxyPort,
|
||||
sniff: true,
|
||||
sniff_override_destination: true,
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "mixed",
|
||||
tag: "mixed-in",
|
||||
listen: settings.bindIp,
|
||||
listen_port: settings.proxyPort,
|
||||
sniff: true,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
log: {
|
||||
@@ -269,34 +297,21 @@ export function buildGatewayConfig(
|
||||
dns: {
|
||||
independent_cache: true,
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
type: "tproxy",
|
||||
tag: "tproxy-in",
|
||||
listen: "::",
|
||||
listen_port: settings.tproxyPort,
|
||||
sniff: true,
|
||||
sniff_override_destination: true,
|
||||
},
|
||||
{
|
||||
type: "mixed",
|
||||
tag: "mixed-in",
|
||||
listen: settings.bindIp,
|
||||
listen_port: settings.proxyPort,
|
||||
sniff: true,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
],
|
||||
inbounds,
|
||||
outbounds: [
|
||||
vpnOutbound,
|
||||
{ type: "direct", tag: "direct" },
|
||||
{ type: "block", tag: "block" },
|
||||
],
|
||||
route: {
|
||||
rule_set: bypassAll ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rules: bypassAll
|
||||
? [{ ip_is_private: true, outbound: "direct" }]
|
||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
||||
: clientMode
|
||||
? proxyOnlyRules
|
||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||
includeTransparent: !clientMode,
|
||||
}),
|
||||
final: "direct",
|
||||
auto_detect_interface: true,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Topbar } from './components/Topbar.jsx';
|
||||
import { Sidebar } from './components/Sidebar.jsx';
|
||||
import { StatusPane } from './components/StatusPane.jsx';
|
||||
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||
import { ServersPage } from './components/ServersPage.jsx';
|
||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||
import { LogsPage } from './components/LogsPage.jsx';
|
||||
@@ -87,6 +88,12 @@ function App() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
@@ -352,6 +359,7 @@ function App() {
|
||||
() => servers.find((s) => s.tag === state?.selectedTag) || null,
|
||||
[servers, state?.selectedTag],
|
||||
);
|
||||
const isClientMode = state?.mode === 'client';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
@@ -380,24 +388,42 @@ function App() {
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
|
||||
<div className="app-body">
|
||||
<Sidebar active={page} onChange={navigate} badges={sidebarBadges} />
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<main className="app-main">
|
||||
{page === 'overview' && (
|
||||
<OverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
busy={busy}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onNav={navigate}
|
||||
onBypassToggle={toggleBypass}
|
||||
onFlushDirectCache={flushDirectCache}
|
||||
/>
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
busy={busy}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
/>
|
||||
) : (
|
||||
<OverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
busy={busy}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onNav={navigate}
|
||||
onBypassToggle={toggleBypass}
|
||||
onFlushDirectCache={flushDirectCache}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && (
|
||||
{page === 'servers' && !isClientMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
@@ -413,7 +439,7 @@ function App() {
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && (
|
||||
{page === 'routing' && !isClientMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
@@ -431,8 +457,8 @@ function App() {
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && (
|
||||
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && !isClientMode && (
|
||||
<SettingsPage
|
||||
state={state}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
@@ -489,13 +515,15 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
/>
|
||||
{!isClientMode && (
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||
|
||||
256
src/web/components/ClientOverviewPage.jsx
Normal file
256
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
|
||||
function CopyField({ label, value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="copy-field">
|
||||
<div>
|
||||
<small className="muted">{label}</small>
|
||||
<div className="text-mono">{value}</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary sm" onClick={copy}>
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHero({ state, status, activeServer }) {
|
||||
const cfg = {
|
||||
running: {
|
||||
title: 'Локальный proxy работает',
|
||||
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
|
||||
badge: 'Готов',
|
||||
kind: 'success',
|
||||
},
|
||||
applying: {
|
||||
title: 'Применяем сервер',
|
||||
hint: 'sing-box перезапускается',
|
||||
badge: 'Применяем',
|
||||
kind: 'warning',
|
||||
},
|
||||
error: {
|
||||
title: 'Нужна проверка',
|
||||
hint: 'Откройте логи и config',
|
||||
badge: 'Ошибка',
|
||||
kind: 'danger',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Proxy остановлен',
|
||||
hint: 'Конфиг есть, sing-box не запущен',
|
||||
badge: 'Остановлен',
|
||||
kind: 'warning',
|
||||
},
|
||||
no_config: {
|
||||
title: 'Proxy ещё не настроен',
|
||||
hint: 'Загрузите подписку и выберите сервер',
|
||||
badge: 'Не настроен',
|
||||
kind: 'neutral',
|
||||
},
|
||||
}[status] || {};
|
||||
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||
: 'нет данных';
|
||||
|
||||
return (
|
||||
<section className="client-hero">
|
||||
<div className="client-hero-main">
|
||||
<span className={`badge ${cfg.kind}`}>{cfg.badge}</span>
|
||||
<h1>{cfg.title}</h1>
|
||||
<p>{cfg.hint}</p>
|
||||
</div>
|
||||
<div className="client-hero-meta">
|
||||
<div>
|
||||
<small className="muted">Активный сервер</small>
|
||||
<strong>{activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Трафик</small>
|
||||
<strong>{traffic}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Применено</small>
|
||||
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSetup({
|
||||
state,
|
||||
servers,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
}) {
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const canApply = selected && selected !== state?.selectedTag;
|
||||
|
||||
return (
|
||||
<div className="card client-setup">
|
||||
<div className="card-header">
|
||||
<h2>Подключение</h2>
|
||||
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">URL подписки или VLESS-ссылка</label>
|
||||
<div className="subscription-input">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://… или vless://…"
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">VPN-сервер</label>
|
||||
<div className="subscription-input">
|
||||
<select
|
||||
className="select"
|
||||
value={selected}
|
||||
disabled={!servers.length}
|
||||
onChange={(e) => setPendingTag(e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.tag} value={server.tag}>
|
||||
{flagFor(server)} {server.tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
<small className="field-hint">
|
||||
В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyCard({ state }) {
|
||||
const port = state?.proxyPort || 8080;
|
||||
const urls = useMemo(() => ({
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
socks: `socks5://127.0.0.1:${port}`,
|
||||
}), [port]);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Локальный proxy</h2>
|
||||
<span className="badge info">127.0.0.1:{port}</span>
|
||||
</div>
|
||||
<div className="copy-stack">
|
||||
<CopyField label="HTTP / HTTPS" value={urls.http} />
|
||||
<CopyField label="SOCKS5" value={urls.socks} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientFlow({ state, activeServer }) {
|
||||
const steps = [
|
||||
{ label: 'Mac', value: 'приложения' },
|
||||
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
||||
{ label: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' },
|
||||
{ label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Цепочка подключения</h2></div>
|
||||
<div className="client-flow">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.label}>
|
||||
<div className="flow-node">
|
||||
<small>{step.label}</small>
|
||||
<strong>{step.value}</strong>
|
||||
</div>
|
||||
{index < steps.length - 1 && <span className="flow-arrow">→</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientActions({ state, busy, onRestart, onStop }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Управление</h2></div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
|
||||
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
status,
|
||||
activeServer,
|
||||
busy,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
}) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<ClientHero state={state} status={status} activeServer={activeServer} />
|
||||
<ClientSetup
|
||||
state={state}
|
||||
servers={servers}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
busy={busy}
|
||||
onFetchSubscription={onFetchSubscription}
|
||||
onApply={onApply}
|
||||
/>
|
||||
<div className="grid-2">
|
||||
<ProxyCard state={state} />
|
||||
<ClientActions
|
||||
state={state}
|
||||
busy={busy}
|
||||
onRestart={onRestart}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
<ClientFlow state={state} activeServer={activeServer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -741,18 +741,16 @@ function RuleSetsCard({ pushToast }) {
|
||||
}
|
||||
|
||||
function PortsCard({ state }) {
|
||||
const isClient = state?.mode === 'client';
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Порты и маршруты</h2></div>
|
||||
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
|
||||
<div className="row"><span className="key">Mixed proxy (http+socks5)</span><span className="val text-mono">{state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
|
||||
<div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>
|
||||
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
|
||||
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
|
||||
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginTop: 10 }}>
|
||||
Эти параметры задаются в config.js на сервере.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ const NAV = [
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {} }) {
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
{NAV.map((item) => {
|
||||
{items.map((item) => {
|
||||
const badge = badges[item.id];
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -25,11 +25,13 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||
: null;
|
||||
|
||||
const isClient = state?.mode === 'client';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<span className="logo-dot" />
|
||||
VPN Gateway
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
@@ -52,10 +54,10 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{dirty && (
|
||||
{!isClient && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{state?.previousTag && (
|
||||
{!isClient && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
@@ -66,7 +68,7 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Restart
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -116,6 +116,9 @@ code, .mono {
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
|
||||
min-height: 0;
|
||||
}
|
||||
.app-body.client-mode {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: var(--space-6);
|
||||
@@ -129,6 +132,7 @@ code, .mono {
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-body { grid-template-columns: 1fr; }
|
||||
.app-body.client-mode { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.app-main { padding: var(--space-4); }
|
||||
}
|
||||
@@ -821,6 +825,124 @@ code, .mono {
|
||||
}
|
||||
.subscription-input .input { flex: 1; }
|
||||
|
||||
/* ============ Client overview ============ */
|
||||
|
||||
.client-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
|
||||
gap: var(--space-4);
|
||||
align-items: stretch;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.client-mode .app-main {
|
||||
max-width: 1120px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.client-hero-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.client-hero-main h1 {
|
||||
font-size: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.client-hero-main p {
|
||||
color: var(--muted);
|
||||
}
|
||||
.client-hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.client-hero-meta {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-content: center;
|
||||
}
|
||||
.client-hero-meta > div {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.client-hero-meta strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.copy-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.client-setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.copy-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.copy-field .text-mono {
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.client-flow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
.flow-node {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.flow-node strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.client-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.client-flow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.flow-arrow {
|
||||
justify-content: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.copy-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* For drawer rule editor */
|
||||
.field-row {
|
||||
display: grid;
|
||||
|
||||
47
test/server/singbox-client-mode.test.js
Normal file
47
test/server/singbox-client-mode.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
process.env.APP_MODE = "client";
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-proxy-test-"));
|
||||
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 subscriptionConfig = {
|
||||
outbounds: [
|
||||
{
|
||||
type: "vless",
|
||||
tag: "test-vpn",
|
||||
server: "vpn.example.test",
|
||||
server_port: 443,
|
||||
uuid: "00000000-0000-4000-8000-000000000000",
|
||||
tls: { enabled: true },
|
||||
},
|
||||
],
|
||||
customRules: [],
|
||||
};
|
||||
|
||||
test("client mode exposes only the local mixed proxy inbound", () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.deepEqual(
|
||||
config.inbounds.map((inbound) => inbound.tag),
|
||||
["mixed-in"],
|
||||
);
|
||||
assert.equal(config.inbounds[0].type, "mixed");
|
||||
assert.equal(config.inbounds[0].listen_port, 8080);
|
||||
});
|
||||
|
||||
test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.deepEqual(config.route.rule_set, []);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user