diff --git a/.env.example b/.env.example index d36b246..94a2185 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index 4b1c4d4..6ae77c6 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -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 diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 0000000..fc78c8e --- /dev/null +++ b/Dockerfile.client @@ -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"] diff --git a/Dockerfile.runtime-base b/Dockerfile.runtime-base index 523f047..1f96208 100644 --- a/Dockerfile.runtime-base +++ b/Dockerfile.runtime-base @@ -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; \ diff --git a/README.md b/README.md index 54abd38..8204a41 100644 --- a/README.md +++ b/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://: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 | diff --git a/docker-compose.client.yml b/docker-compose.client.yml new file mode 100644 index 0000000..91826f6 --- /dev/null +++ b/docker-compose.client.yml @@ -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: diff --git a/docs/superpowers/plans/2026-05-19-macos-client.md b/docs/superpowers/plans/2026-05-19-macos-client.md new file mode 100644 index 0000000..7e3f5e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-macos-client.md @@ -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. diff --git a/docs/superpowers/specs/2026-05-19-macos-client-design.md b/docs/superpowers/specs/2026-05-19-macos-client-design.md new file mode 100644 index 0000000..b25b041 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-macos-client-design.md @@ -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`. diff --git a/entrypoint.client.sh b/entrypoint.client.sh new file mode 100755 index 0000000..bde2b06 --- /dev/null +++ b/entrypoint.client.sh @@ -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 diff --git a/package.json b/package.json index 7e2ac9c..7e3fec2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/build-runtime-base.sh b/scripts/build-runtime-base.sh index b8a2754..593a26c 100755 --- a/scripts/build-runtime-base.sh +++ b/scripts/build-runtime-base.sh @@ -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}" \ diff --git a/scripts/install-macos-client.sh b/scripts/install-macos-client.sh new file mode 100755 index 0000000..c06f49d --- /dev/null +++ b/scripts/install-macos-client.sh @@ -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 diff --git a/src/server/config.js b/src/server/config.js index 52ad692..ab0a8e3 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -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), diff --git a/src/server/index.js b/src/server/index.js index 10b3d51..aa071ca 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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), diff --git a/src/server/singbox.js b/src/server/singbox.js index e6db187..ad6f680 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -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, }, diff --git a/src/web/App.jsx b/src/web/App.jsx index e250e2f..22d67f9 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -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} /> -
- +
+ {!isClientMode && }
- {page === 'overview' && ( - setConfigOpen(true)} - onNav={navigate} - onBypassToggle={toggleBypass} - onFlushDirectCache={flushDirectCache} - /> + {(page === 'overview' || isClientMode) && ( + isClientMode ? ( + + ) : ( + setConfigOpen(true)} + onNav={navigate} + onBypassToggle={toggleBypass} + onFlushDirectCache={flushDirectCache} + /> + ) )} - {page === 'servers' && ( + {page === 'servers' && !isClientMode && ( )} - {page === 'routing' && ( + {page === 'routing' && !isClientMode && ( )} - {page === 'logs' && } - {page === 'settings' && ( + {page === 'logs' && !isClientMode && } + {page === 'settings' && !isClientMode && ( - setConfigOpen(true)} - /> + {!isClientMode && ( + setConfigOpen(true)} + /> + )}
setConfigOpen(false)} /> diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx new file mode 100644 index 0000000..dc7a40f --- /dev/null +++ b/src/web/components/ClientOverviewPage.jsx @@ -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 ( +
+
+ {label} +
{value}
+
+ +
+ ); +} + +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 ( +
+
+ {cfg.badge} +

{cfg.title}

+

{cfg.hint}

+
+
+
+ Активный сервер + {activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'} +
+
+ Трафик + {traffic} +
+
+ Применено + {state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'} +
+
+
+ ); +} + +function ClientSetup({ + state, + servers, + subscriptionUrl, + setSubscriptionUrl, + pendingTag, + setPendingTag, + busy, + onFetchSubscription, + onApply, +}) { + const selected = pendingTag || state?.selectedTag || ''; + const canApply = selected && selected !== state?.selectedTag; + + return ( +
+
+

Подключение

+ {state?.hasSubscription && Подписка загружена} +
+ +
+ +
+ setSubscriptionUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()} + /> + +
+
+ +
+ +
+ + +
+ + В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN. + +
+
+ ); +} + +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 ( +
+
+

Локальный proxy

+ 127.0.0.1:{port} +
+
+ + +
+
+ ); +} + +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 ( +
+

Цепочка подключения

+
+ {steps.map((step, index) => ( + +
+ {step.label} + {step.value} +
+ {index < steps.length - 1 && } +
+ ))} +
+
+ ); +} + +function ClientActions({ state, busy, onRestart, onStop }) { + return ( +
+

Управление

+
+ + +
+
+ ); +} + +export function ClientOverviewPage({ + state, + status, + activeServer, + busy, + subscriptionUrl, + setSubscriptionUrl, + servers, + pendingTag, + setPendingTag, + onFetchSubscription, + onApply, + onRestart, + onStop, +}) { + return ( +
+ + +
+ + +
+ +
+ ); +} diff --git a/src/web/components/SettingsPage.jsx b/src/web/components/SettingsPage.jsx index ec76953..b5d972f 100644 --- a/src/web/components/SettingsPage.jsx +++ b/src/web/components/SettingsPage.jsx @@ -741,18 +741,16 @@ function RuleSetsCard({ pushToast }) { } function PortsCard({ state }) { + const isClient = state?.mode === 'client'; return (
-

Порты и маршруты

+

{isClient ? 'Локальные порты' : 'Порты и маршруты'}

UI:{state?.port || 3456}
-
Mixed proxy (http+socks5){state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}
-
TProxy:{state?.tproxyPort || 7895}
+
HTTP/SOCKS proxy{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}
+ {!isClient &&
TProxy:{state?.tproxyPort || 7895}
}
RU direct (geoip-ru){state?.routingRuDirect ? 'включено' : 'выключено'}
- - Эти параметры задаются в config.js на сервере. -
); } diff --git a/src/web/components/Sidebar.jsx b/src/web/components/Sidebar.jsx index bc64734..a22f8b6 100644 --- a/src/web/components/Sidebar.jsx +++ b/src/web/components/Sidebar.jsx @@ -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 (
diff --git a/src/web/styles.css b/src/web/styles.css index d467c14..b10884e 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js new file mode 100644 index 0000000..1b72770 --- /dev/null +++ b/test/server/singbox-client-mode.test.js @@ -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" }, + ]); +});