Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d4c61783 | |||
| f4990a4f55 | |||
| ab44626a0f | |||
| 95edefa84f | |||
| f914c28bc5 | |||
| 73488384e4 | |||
| c6352d781f | |||
| d02dbe10de | |||
| 2ef1e09986 | |||
| 6df8c525ef | |||
| f264ce4a2f | |||
| 371adbcb50 | |||
| 3a930c9d8c | |||
| 1bdf12f174 | |||
| 3e8925c609 | |||
| d12b0c01fc | |||
| e16f401dc5 | |||
| 68844d67df | |||
| ec8e748a43 | |||
| 62f50d9c28 | |||
| cab4313c70 | |||
| aab7533438 | |||
| 62b39cdf58 | |||
| 6ab5f50f95 | |||
| 4bb8507e3f | |||
| b3fad00f80 | |||
| 5c9a291920 | |||
| 781cbbb026 | |||
| 499d2d3367 | |||
| eeec4359b0 | |||
| 11f2c0ccb2 | |||
| f89cba4a24 | |||
| 49be90a82c | |||
| bb7250e4ac | |||
| 4f1a2f8bf6 | |||
| 7d1f5f89ed | |||
| b1c8eea976 | |||
| 27b71077b1 | |||
| 3e18b833c6 | |||
| 0cd898d1c1 | |||
| 8476ab16e5 | |||
| a8f2c6f3f9 | |||
| a961b1b415 | |||
| 7489b5ef97 | |||
| b716b370ac | |||
| abd5a73b51 | |||
| 1ed79c3a1e | |||
| 8789496ae6 | |||
| 7d41dd86e7 | |||
| 81bed1513c | |||
| d13eb0a9a4 | |||
| 71f8e0b84c | |||
| 03885d2e09 | |||
| 88eef527d5 | |||
| c971b40eae | |||
| 327561b2e9 | |||
| 185a311a38 | |||
| ef752d66bc | |||
| a3816cbedc | |||
| 51d26a4c1b | |||
| 638940c694 | |||
| 2e16d33618 | |||
| 6b38c7b15f | |||
| 6e97bb9f61 | |||
| c4915389a7 | |||
| 48178fa3ae | |||
| ede0370b3a | |||
| 116856c1d1 | |||
| 13c92c7413 | |||
| 479a7232b1 | |||
| e1f71f95ad | |||
| d7a3b20da9 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.vpn-proxy
|
||||
_archive
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.vscode
|
||||
*.log
|
||||
.DS_Store
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
PORT=3456
|
||||
APP_MODE=gateway
|
||||
CLIENT_UI_PORT=3456
|
||||
CLIENT_PROXY_PORT_START=8080
|
||||
CLIENT_PROXY_PORT_END=8090
|
||||
BASE_IMAGE=debian:bookworm-slim
|
||||
SINGBOX_VERSION=1.12.13
|
||||
INSTALL_RUNTIME_DEPS=true
|
||||
INSTALL_SINGBOX=true
|
||||
PROXY_PORT=8080
|
||||
PROXY_BIND_IP=0.0.0.0
|
||||
TPROXY_PORT=7895
|
||||
TPROXY_MARK=1
|
||||
TPROXY_TABLE=100
|
||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||
ROUTING_RU_DIRECT=true
|
||||
LOG_LEVEL=info
|
||||
107
.gitea/workflows/gateway-build.yml
Normal file
107
.gitea/workflows/gateway-build.yml
Normal file
@@ -0,0 +1,107 @@
|
||||
name: Build and Deploy Gateway
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
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:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Clone repository
|
||||
env:
|
||||
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||
rm -rf repo
|
||||
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||
cd repo
|
||||
git checkout ${{ gitea.sha }}
|
||||
|
||||
- name: Build and push gateway image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd repo
|
||||
|
||||
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||
|
||||
echo "Build runner: $(hostname)"
|
||||
echo "Base image: ${{ env.BASE_IMAGE }}"
|
||||
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
||||
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'
|
||||
|
||||
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||
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
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
else
|
||||
echo "Host npm not found; building frontend inside ${{ env.BASE_IMAGE }}"
|
||||
docker run --rm \
|
||||
--network host \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
"${{ env.BASE_IMAGE }}" \
|
||||
sh -lc 'npm ci --no-audit --no-fund && npm run build'
|
||||
fi
|
||||
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--network host \
|
||||
--pull=false \
|
||||
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||
--build-arg INSTALL_RUNTIME_DEPS=false \
|
||||
--build-arg INSTALL_SINGBOX=false \
|
||||
-t "${IMAGE}:latest" \
|
||||
-t "${IMAGE}:${{ gitea.sha }}" \
|
||||
.
|
||||
docker push "${IMAGE}:latest"
|
||||
docker push "${IMAGE}:${{ gitea.sha }}"
|
||||
|
||||
deploy:
|
||||
runs-on: lxc-111
|
||||
needs: build-and-push
|
||||
steps:
|
||||
- name: Clone repository
|
||||
env:
|
||||
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||
rm -rf repo
|
||||
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||
cd repo
|
||||
git checkout ${{ gitea.sha }}
|
||||
|
||||
- name: Pull and deploy gateway image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd repo
|
||||
|
||||
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||
|
||||
echo "Deploy runner: $(hostname)"
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" GATEWAY_IMAGE="${IMAGE}:${{ gitea.sha }}" bash scripts/deploy-gateway.sh
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -1,2 +1,25 @@
|
||||
data
|
||||
_legacy
|
||||
# Local archive with the previous implementation and runtime secrets
|
||||
_archive/
|
||||
|
||||
# Runtime state
|
||||
.env
|
||||
*.env.local
|
||||
data/
|
||||
.vpn-proxy/
|
||||
.superpowers/
|
||||
|
||||
# Node/Vite
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS/editors
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
ARG SINGBOX_VERSION=1.12.13
|
||||
ARG INSTALL_RUNTIME_DEPS=true
|
||||
ARG INSTALL_SINGBOX=true
|
||||
COPY dist /app/dist
|
||||
|
||||
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
else \
|
||||
command -v dumb-init >/dev/null \
|
||||
&& command -v node >/dev/null \
|
||||
&& command -v iptables >/dev/null \
|
||||
&& command -v ipset >/dev/null; \
|
||||
fi
|
||||
|
||||
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||
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*; \
|
||||
else \
|
||||
command -v sing-box >/dev/null; \
|
||||
fi
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json /app/package.json
|
||||
COPY src/server /app/src/server
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||
|
||||
ENV PORT=3456 \
|
||||
PROXY_PORT=8080 \
|
||||
PROXY_BIND_IP=0.0.0.0 \
|
||||
TPROXY_PORT=7895 \
|
||||
DIRECT_BYPASS_CACHE=false \
|
||||
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||
DATA_DIR=/var/lib/vpn-proxy \
|
||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
||||
|
||||
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||
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"]
|
||||
52
Dockerfile.runtime-base
Normal file
52
Dockerfile.runtime-base
Normal file
@@ -0,0 +1,52 @@
|
||||
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
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
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:-}}" \
|
||||
&& 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; \
|
||||
export http_proxy="${http_proxy:-${HTTP_PROXY:-}}"; \
|
||||
export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}"; \
|
||||
export no_proxy="${no_proxy:-${NO_PROXY:-}}"; \
|
||||
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*
|
||||
643
README.md
643
README.md
@@ -1,353 +1,450 @@
|
||||
# 🌐 VPN Proxy — Домашний VPN в одной программе
|
||||
# VPN Proxy
|
||||
|
||||
> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях.
|
||||
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||
|
||||
---
|
||||
## macOS: локальный Docker-клиент
|
||||
|
||||
## 📖 Что это такое?
|
||||
|
||||
Это набор инструментов, который позволяет:
|
||||
|
||||
1. **Запустить VPN-прокси** на вашем компьютере
|
||||
2. **Управлять через удобное меню** — всё настраивается автоматически
|
||||
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
|
||||
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
|
||||
|
||||
### 🎯 Для кого это?
|
||||
|
||||
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
|
||||
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
|
||||
- Геймеры, которым нужно запустить игры или Discord через VPN
|
||||
- Люди, которые получили VLESS ссылку от VPN-провайдера
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Как это работает?
|
||||
Самый простой режим: контейнер работает как обычный локальный 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
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет
|
||||
│ или Discord │ │ (порт 1080) │ │ (в другой стране)│
|
||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
|
||||
После запуска по умолчанию:
|
||||
|
||||
- UI: `http://127.0.0.1:3456`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `8080–8090`
|
||||
|
||||
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
|
||||
```
|
||||
|
||||
Если старый gateway/client уже занимает `3456` или выбранный proxy-порт, можно не трогать старый контейнер и поставить новый клиент на другие порты:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_UI_PORT=3457 VPN_PROXY_CLIENT_PORT=18080 bash
|
||||
```
|
||||
|
||||
После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности.
|
||||
|
||||
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
|
||||
|
||||
Также Mac-клиент можно связать с серверным gateway. На gateway доступна ручка:
|
||||
|
||||
```bash
|
||||
GET http://<gateway-ui-host>:3456/api/shared-proxy
|
||||
```
|
||||
|
||||
Если gateway запущен и его mixed proxy работает, ручка вернёт `available: true` и SOCKS5 endpoint общего proxy. В Mac UI укажите адрес gateway UI, например `http://192.168.50.111:3456`. Клиент проверит ручку и переключит локальный `127.0.0.1:<proxy-port>` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна.
|
||||
|
||||
Ручной запуск из 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
|
||||
|
||||
### ✅ PowerShell 7 (Обязательно!)
|
||||
Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.
|
||||
|
||||
> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!**
|
||||
|
||||
#### Проверьте вашу версию
|
||||
|
||||
Откройте любой PowerShell и выполните:
|
||||
|
||||
```powershell
|
||||
$PSVersionTable.PSVersion.Major
|
||||
```
|
||||
|
||||
- Если результат **7 или выше** — всё хорошо, переходите к установке ✅
|
||||
- Если **5 или ниже** — нужно установить PowerShell 7 👇
|
||||
|
||||
#### Установка PowerShell 7
|
||||
|
||||
**Способ 1: Через winget (самый простой)**
|
||||
|
||||
Откройте обычный PowerShell или Командную строку и выполните:
|
||||
|
||||
```powershell
|
||||
winget install Microsoft.PowerShell
|
||||
```
|
||||
|
||||
После установки закройте окно и откройте **PowerShell 7** (он появится в меню Пуск).
|
||||
|
||||
**Способ 2: Скачать вручную**
|
||||
|
||||
1. Перейдите: https://github.com/PowerShell/PowerShell/releases/latest
|
||||
2. Скачайте файл `PowerShell-7.x.x-win-x64.msi` (где x.x.x — версия)
|
||||
3. Запустите установщик и следуйте инструкциям
|
||||
4. После установки используйте **PowerShell 7** из меню Пуск
|
||||
|
||||
> 💡 **Как отличить?** PowerShell 7 имеет чёрный фон и надпись "pwsh" или "PowerShell 7". Старый PowerShell — синий фон.
|
||||
Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
|
||||
|
||||
---
|
||||
|
||||
### ✅ URL Подписки или VLESS-ссылка
|
||||
## Архитектура
|
||||
|
||||
Получите от вашего VPN-провайдера:
|
||||
- **Подписку**: URL, который начинается с `http://` или `https://`
|
||||
- **VLESS-ссылку**: начинается с `vless://...`
|
||||
```
|
||||
Клиент (ПК/телефон)
|
||||
│ TCP/UDP трафик
|
||||
▼
|
||||
[Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
|
||||
│
|
||||
▼
|
||||
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
||||
│
|
||||
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
|
||||
├─ приватные CIDR (RFC1918, ...) → RETURN
|
||||
└─ TCP/UDP → TPROXY :7895
|
||||
│
|
||||
▼
|
||||
sing-box (tproxy inbound :7895)
|
||||
│
|
||||
роутинг по правилам
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
▼ ▼ ▼
|
||||
direct VPN out block
|
||||
```
|
||||
|
||||
ПК-приложения, которым нужен VPN явно:
|
||||
|
||||
```
|
||||
Windows app → ProxiFyre/Proxifier → gateway:8080 → sing-box mixed-in → global rules → default VPN
|
||||
```
|
||||
|
||||
**Node.js API-сервер** (`src/server/index.js`) работает внутри того же контейнера:
|
||||
управляет процессом sing-box, парсит его логи, экспортирует REST API и SSE-стримы для веб-интерфейса.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Установка на Windows
|
||||
## Стек
|
||||
|
||||
### ⚡ Быстрая установка (Одной командой)
|
||||
|
||||
Самый быстрый способ — использовать наш автоматический установщик. Он сам скачает проект и распакует его в `C:\Tools\vpn-proxy`.
|
||||
|
||||
1. Откройте **PowerShell 7** от имени **Администратора**
|
||||
2. Скопируйте и вставьте команду:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy RemoteSigned -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
|
||||
```
|
||||
|
||||
> 💡 Если команда выдаст ошибку 404, попробуйте заменить `master` на `main` в ссылке, или используйте ручную установку ниже.
|
||||
| Слой | Технология |
|
||||
| ---------------- | ------------------------------------------------------------- |
|
||||
| Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
|
||||
| Перехват трафика | iptables TProxy + iproute2 policy routing |
|
||||
| Bypass-кэш | опциональный ipset `hash:ip` с TTL |
|
||||
| VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
|
||||
| API-сервер | Node.js 18, plain `http` (без фреймворков) |
|
||||
| Веб-интерфейс | React 18 + Vite 7, SPA |
|
||||
|
||||
---
|
||||
|
||||
### 📦 Ручная установка (если авто-установка не работает)
|
||||
## Как работает прозрачное проксирование
|
||||
|
||||
Если вы предпочитаете всё делать сами:
|
||||
### 1. TProxy и policy routing
|
||||
|
||||
#### Шаг 1: Скачайте проект
|
||||
При старте контейнера `entrypoint.sh` настраивает ядро:
|
||||
|
||||
Мы рекомендуем использовать папку `C:\Tools`.
|
||||
```bash
|
||||
# Policy routing: пакеты с меткой TPROXY_MARK уходят через loopback
|
||||
ip rule add fwmark 1 table 100
|
||||
ip route replace local 0.0.0.0/0 dev lo table 100
|
||||
|
||||
```powershell
|
||||
# 1. Создаем папку и переходим
|
||||
New-Item -ItemType Directory -Force -Path "C:\Tools" | Out-Null
|
||||
cd C:\Tools
|
||||
|
||||
# 2. Клонируем или скачиваем архив
|
||||
git clone https://git.dokops.ru/dokril/vpn-proxy
|
||||
|
||||
# (Или скачайте ZIP вручную и распакуйте в C:\Tools\vpn-proxy)
|
||||
# Цепочка iptables (порядок правил — критичен)
|
||||
iptables -t mangle -N VPN_PROXY_TPROXY
|
||||
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
|
||||
-m mark --mark 1 → RETURN # уже помеченные пакеты
|
||||
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
|
||||
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
||||
-p tcp → TPROXY :7895 mark 1
|
||||
-p udp → TPROXY :7895 mark 1
|
||||
iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
||||
```
|
||||
|
||||
#### Шаг 2: Запустите
|
||||
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
||||
|
||||
```powershell
|
||||
cd C:\Tools\vpn-proxy
|
||||
.\manage.ps1
|
||||
```
|
||||
### 2. Маршрутизация внутри sing-box
|
||||
|
||||
### Шаг 3: Выберите пункт [1] — VPN Клиент
|
||||
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||
|
||||
```
|
||||
[1] 📦 VPN Клиент (Sing-box) [НЕ УСТАНОВЛЕН]
|
||||
Основной способ. Поддерживает UDP и игры.
|
||||
| Приоритет | Условие | Действие |
|
||||
| --------- | ------------------------------------------- | ---------------------------------------- |
|
||||
| 1 | `ip_is_private: true` | `direct` (защита LAN) |
|
||||
| 2 | Global custom rules | `direct` / VPN / `block` для всех inbound |
|
||||
| 3 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` |
|
||||
| 4 | Device defaults для `tproxy-in` | `direct` / VPN / `block` |
|
||||
| 5 | Proxy default для `mixed-in` | по умолчанию VPN |
|
||||
| 6 | Transparent default для unknown devices | по умолчанию VPN |
|
||||
| 7 | Всё остальное (`final`) | `direct` |
|
||||
|
||||
[2] 🎮 Настройка Discord/Vesktop [НЕ АКТИВЕН]
|
||||
Маршрутизация приложений через прокси.
|
||||
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
|
||||
|
||||
---------------------------------------
|
||||
[3] 🔄 Обновить статус
|
||||
[U] ❌ Удалить всё (Uninstall)
|
||||
[q] Выход
|
||||
### 3. Bypass Mode (весь трафик напрямую)
|
||||
|
||||
👉 Ваш выбор: 1
|
||||
```
|
||||
|
||||
### Шаг 4: Введите VLESS-ссылку или URL подписки
|
||||
|
||||
Скрипт попросит ввести ссылку. Вставьте и нажмите Enter.
|
||||
|
||||
**Готово!** 🎉 Прокси запущен на `127.0.0.1:1080`
|
||||
|
||||
### 📂 Где всё хранится?
|
||||
|
||||
Всё организовано в папке `C:\Tools`:
|
||||
|
||||
1. **Сам проект:** `C:\Tools\vpn-proxy`
|
||||
- Скрипты управления и настройки
|
||||
2. **Sing-box (VPN клиент):** `C:\Tools\sing-box`
|
||||
- Здесь лежит `config.json` с вашими настройками и сам исполняемый файл
|
||||
3. **ProxiFyre (для Discord):** `C:\Program Files\ProxiFyre` (системная служба)
|
||||
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Проверка работы
|
||||
## Direct Bypass Cache (ipset)
|
||||
|
||||
После установки меню покажет статус и адреса подключения:
|
||||
Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`.
|
||||
|
||||
Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace.
|
||||
|
||||
**Цепочка событий:**
|
||||
|
||||
1. sing-box маршрутизирует соединение как `direct`, пишет в лог:
|
||||
`[TCP] 192.168.1.5:54321 --> 203.0.113.10:443 outbound/direct[direct]`
|
||||
|
||||
2. Node.js парсит строку (regex `-->` + `outbound/`). Если `category === "direct"` и назначение — IPv4-адрес:
|
||||
|
||||
```bash
|
||||
ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
|
||||
```
|
||||
|
||||
3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:
|
||||
|
||||
```
|
||||
-m set --match-set vpn_direct_bypass dst → RETURN
|
||||
```
|
||||
|
||||
Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.
|
||||
|
||||
4. Запись истекает через TTL (по умолчанию 1 час).
|
||||
|
||||
```
|
||||
[1] 📦 VPN Клиент (Sing-box) [РАБОТАЕТ]
|
||||
Основной способ. Поддерживает UDP и игры.
|
||||
|
||||
📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ
|
||||
─────────────────────────────
|
||||
Локально: 127.0.0.1:1080
|
||||
Из сети:
|
||||
192.168.1.100:1080
|
||||
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
|
||||
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
|
||||
DIRECT_BYPASS_TTL=3600 # TTL в секундах
|
||||
```
|
||||
|
||||
### Проверка через терминал
|
||||
## Профили устройств
|
||||
|
||||
```powershell
|
||||
# Без прокси — покажет ваш домашний IP
|
||||
Invoke-WebRequest -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
||||
|
||||
# Через прокси — должен показать IP VPN-сервера
|
||||
Invoke-WebRequest -Proxy "http://127.0.0.1:1080" -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
||||
```
|
||||
|
||||
Если IP-адреса разные — VPN работает! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Настройка Discord / Vesktop
|
||||
|
||||
Discord не поддерживает системные настройки прокси, поэтому нужна дополнительная настройка.
|
||||
|
||||
### Требования
|
||||
|
||||
- ✅ Установленный VPN клиент (пункт [1] в меню)
|
||||
- ✅ VPN клиент должен быть запущен (статус "РАБОТАЕТ")
|
||||
|
||||
### Установка
|
||||
|
||||
1. Запустите `.\manage.ps1`
|
||||
2. Выберите пункт **[2] — Настройка Discord/Vesktop**
|
||||
3. Выберите какое приложение настроить:
|
||||
- Discord
|
||||
- Vesktop
|
||||
- Оба
|
||||
|
||||
**Что устанавливается:**
|
||||
- Windows Packet Filter — драйвер для перехвата трафика
|
||||
- ProxiFyre — служба, которая направляет трафик Discord через прокси
|
||||
|
||||
После установки Discord/Vesktop будут автоматически работать через VPN!
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройка приложений
|
||||
|
||||
### Для VS Code
|
||||
|
||||
Откройте настройки (Ctrl + ,), найдите "proxy" и добавьте:
|
||||
|
||||
```
|
||||
http.proxy: http://127.0.0.1:1080
|
||||
```
|
||||
|
||||
Или добавьте в `settings.json`:
|
||||
Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"http.proxy": "http://127.0.0.1:1080",
|
||||
"http.proxyStrictSSL": true
|
||||
"defaultTransparentMode": "vpn",
|
||||
"proxyDefaultMode": "vpn",
|
||||
"devices": [
|
||||
{
|
||||
"id": "gaming-pc",
|
||||
"name": "Gaming PC",
|
||||
"ip": "192.168.1.50",
|
||||
"mac": "",
|
||||
"mode": "direct",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "phone",
|
||||
"name": "Phone",
|
||||
"ip": "192.168.1.60",
|
||||
"mode": "vpn",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Для браузера
|
||||
| Mode | Что делает |
|
||||
| -------- | ----------------------------------------------------------------- |
|
||||
| `direct` | fallback устройства после global rules → `direct` |
|
||||
| `vpn` | fallback устройства после global rules → выбранный VPN |
|
||||
| `block` | fallback устройства после global rules → `block` |
|
||||
| `rules` | не задаёт fallback устройства; используется transparent default |
|
||||
|
||||
В настройках прокси вашего браузера укажите:
|
||||
|
||||
- **Тип**: HTTP или SOCKS5
|
||||
- **Адрес**: `127.0.0.1`
|
||||
- **Порт**: `1080`
|
||||
|
||||
> 💡 **Совет:** Используйте расширение [Proxy SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif) для удобного переключения прокси в Chrome.
|
||||
|
||||
### Для других программ
|
||||
|
||||
Укажите SOCKS5 прокси: `127.0.0.1:1080`
|
||||
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Управление
|
||||
## Кастомные правила маршрутизации
|
||||
|
||||
При повторном запуске `.\manage.ps1` скрипт покажет меню управления:
|
||||
Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
|
||||
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.
|
||||
|
||||
| Действие | Как сделать |
|
||||
|----------|-------------|
|
||||
| Посмотреть статус | Запустить `.\manage.ps1` |
|
||||
| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" |
|
||||
| Перезапустить | Пункт [1] → "Перезапустить" |
|
||||
| Остановить | Пункт [1] → "Остановить" |
|
||||
| Полностью удалить | Пункт [U] |
|
||||
| Поле | Тип | Описание |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------- |
|
||||
| `name` | string | Название правила |
|
||||
| `enabled` | bool | Вкл/выкл |
|
||||
| `outbound` | `direct` \| `vpn` \| `block` | Куда отправить трафик |
|
||||
| `domains` | string[] | Точные домены (`example.com`) |
|
||||
| `domainSuffixes` | string[] | Суффикс домена (`.example.com` + поддомены) |
|
||||
| `domainKeywords` | string[] | Keyword в имени хоста |
|
||||
| `ipCidrs` | string[] | IP-диапазоны CIDR |
|
||||
| `ports` | string[] | Порты или диапазоны (`443`, `8000-9000`) |
|
||||
| `networks` | `tcp` \| `udp` | Протокол |
|
||||
| `ruleSets` | string[] | Ссылки на remote rule-set |
|
||||
|
||||
---
|
||||
UI автоматически детектирует конфликты — когда правило полностью перекрывается предыдущим.
|
||||
|
||||
## 🌍 Подключение из локальной сети
|
||||
### Remote Rule Sets
|
||||
|
||||
Если вы хотите использовать прокси с других устройств (телефон, планшет):
|
||||
В **Настройках** можно добавить произвольные rule-set:
|
||||
|
||||
1. Посмотрите IP-адрес в меню (раздел "Из сети:")
|
||||
2. На другом устройстве настройте прокси: `IP_ВАШЕГО_ПК:1080`
|
||||
|
||||
Например: `192.168.1.100:1080`
|
||||
|
||||
---
|
||||
|
||||
## ❓ Часто задаваемые вопросы
|
||||
|
||||
### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено"
|
||||
|
||||
**Решение:** Включите выполнение скриптов:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```json
|
||||
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
|
||||
```
|
||||
|
||||
### Ошибка при запуске — непонятные символы или синтаксис
|
||||
|
||||
**Причина:** Вы используете старый PowerShell 5.1
|
||||
|
||||
**Решение:** Установите PowerShell 7 (см. раздел "Перед началом")
|
||||
|
||||
### Discord не подключается к голосовым каналам
|
||||
|
||||
**Причина:** ProxiFyre не запущен или VPN клиент остановлен
|
||||
|
||||
**Решение:**
|
||||
1. Запустите `.\manage.ps1`
|
||||
2. Убедитесь что пункт [1] показывает "РАБОТАЕТ"
|
||||
3. Убедитесь что пункт [2] показывает "АКТИВЕН"
|
||||
|
||||
### Как узнать, работает ли VPN?
|
||||
|
||||
1. Откройте https://ipinfo.io в браузере — это ваш реальный IP
|
||||
2. Настройте прокси в браузере
|
||||
3. Откройте https://ipinfo.io снова — должен показать другой IP
|
||||
sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Продвинутые варианты
|
||||
## Подписки
|
||||
|
||||
### Docker с веб-интерфейсом
|
||||
Поддерживаемые форматы:
|
||||
|
||||
Если вы предпочитаете управлять через браузер с красивым интерфейсом:
|
||||
- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
|
||||
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
|
||||
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)
|
||||
|
||||
> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**!
|
||||
> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню).
|
||||
После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.
|
||||
|
||||
📖 **[Инструкция по Docker](docs/DOCKER.md)**
|
||||
|
||||
### Установка на удалённый сервер (VPS)
|
||||
|
||||
Если вы хотите развернуть прокси на своём сервере в другой стране:
|
||||
|
||||
📖 **[Инструкция по установке на сервер](docs/SERVER.md)**
|
||||
Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Словарь терминов
|
||||
## Просмотр трафика
|
||||
|
||||
| Термин | Объяснение |
|
||||
|--------|------------|
|
||||
| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени |
|
||||
| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером |
|
||||
| **VLESS** | Современный протокол VPN-соединения |
|
||||
| **sing-box** | Программа-клиент для подключения к VPN |
|
||||
| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) |
|
||||
| **Порт** | "Номер двери" для сетевых соединений |
|
||||
Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).
|
||||
|
||||
### Парсинг логов sing-box
|
||||
|
||||
Node.js читает stderr sing-box и извлекает трафик двумя шагами:
|
||||
|
||||
```
|
||||
[router] match[2][my-rule] => outbound/direct[direct] ← имя правила
|
||||
[TCP] 192.168.1.5:PORT --> example.com:443 outbound/vpn[tag] ← соединение
|
||||
```
|
||||
|
||||
1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
|
||||
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
|
||||
3. Тип трафика: `direct` / `vpn` / `block` по outbound
|
||||
4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true`
|
||||
|
||||
### Группировка и сортировка
|
||||
|
||||
`(category, host, port, matchedRule)` объединяются в группу с счётчиком:
|
||||
|
||||
- **По частоте** — самые частые наверху (по умолчанию)
|
||||
- **По времени** — последние наверху
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Нужна помощь?
|
||||
## Проверка маршрута
|
||||
|
||||
Если что-то не работает:
|
||||
Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
|
||||
|
||||
1. Убедитесь что используете **PowerShell 7**
|
||||
2. Запустите от имени **Администратора**
|
||||
3. Проверьте статус в главном меню
|
||||
4. Попробуйте переустановить: пункт [U], затем пункт [1]
|
||||
1. private IP → direct
|
||||
2. global custom rules
|
||||
3. geoip-ru / geosite-category-ru → direct
|
||||
4. `tproxy-in` + device default
|
||||
5. `mixed-in` + proxy default
|
||||
6. final → direct
|
||||
|
||||
---
|
||||
|
||||
_Создано для простого и безопасного доступа в интернет_ 🛡️
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# Сборка фронтенда
|
||||
npm install && npm run build
|
||||
|
||||
# Запуск контейнера
|
||||
docker compose -f docker-compose.gateway.yml up -d
|
||||
```
|
||||
|
||||
Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:
|
||||
|
||||
```bash
|
||||
BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim \
|
||||
docker compose -f docker-compose.gateway.yml build
|
||||
|
||||
docker compose -f docker-compose.gateway.yml up -d
|
||||
```
|
||||
|
||||
Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:
|
||||
|
||||
```bash
|
||||
BUILD_HOST=107 DEPLOY_HOST=111 ./scripts/build-on-107-deploy-111.sh
|
||||
```
|
||||
|
||||
Скрипт собирает image на `BUILD_HOST`, переносит его на `DEPLOY_HOST` через `docker save | docker load` и запускает без `docker pull`. Если `107`/`111` не являются SSH-алиасами, укажите реальные адреса, например `BUILD_HOST=root@192.168.1.107 DEPLOY_HOST=root@192.168.1.111`.
|
||||
|
||||
Чтобы не получать циклическую зависимость "собрать gateway можно только через уже работающий gateway", подготовьте runtime base на `107` один раз:
|
||||
|
||||
```bash
|
||||
./scripts/build-runtime-base.sh
|
||||
```
|
||||
|
||||
После этого CI и `build-on-107-deploy-111.sh` используют локальный `vpn-proxy-runtime-base:bookworm-slim`: основная сборка gateway больше не делает `apt-get`, не качает sing-box и не обращается к Docker Hub за base image.
|
||||
|
||||
UI доступен на `http://<gateway-ip>:3456`.
|
||||
|
||||
На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
| ------------------- | -------------------- | -------------------------------------- |
|
||||
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
|
||||
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
|
||||
| `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
|
||||
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` |
|
||||
| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
|
||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||
| `INSTALL_RUNTIME_DEPS` | `true` | Устанавливать runtime-пакеты в Docker build; `false` для подготовленного runtime base |
|
||||
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
|
||||
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
||||
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
|
||||
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
||||
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
||||
| `DIRECT_BYPASS_CACHE` | `false` | Включить dst-IP bypass cache; по умолчанию выключен |
|
||||
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша |
|
||||
| `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) |
|
||||
| `RULE_SET_DOWNLOAD_DETOUR` | `vpn` | Через какой outbound sing-box скачивает remote rule-set; `vpn` = выбранный сервер |
|
||||
| `PROXY_BIND_IP` | `0.0.0.0` | Bind для HTTP/SOCKS в LAN; можно сузить до IP gateway |
|
||||
| `PROXY_FIREWALL` | `true` | Закрыть `PROXY_PORT` не из allowed CIDR |
|
||||
| `PROXY_ALLOWED_CIDRS` | `10.0.0.0/8 172.16.0.0/12 192.168.0.0/16` | Кто может подключаться к mixed proxy |
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
| --------- | ---------------------- | ------------------------------------ |
|
||||
| `GET` | `/api/state` | Полное состояние системы |
|
||||
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
|
||||
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||
| `GET/PUT` | `/api/rules` | Кастомные правила |
|
||||
| `GET/PUT` | `/api/devices` | Профили устройств и default fallback |
|
||||
| `GET/PUT` | `/api/rule-sets` | Кастомные remote rule-set |
|
||||
| `POST` | `/api/singbox/start` | Запустить sing-box |
|
||||
| `POST` | `/api/singbox/stop` | Остановить sing-box |
|
||||
| `POST` | `/api/singbox/restart` | Перезапустить sing-box |
|
||||
| `POST` | `/api/bypass` | `{ enabled }` — bypass mode |
|
||||
| `GET` | `/api/direct-cache` | Состояние ipset bypass-кэша |
|
||||
| `DELETE` | `/api/direct-cache` | Сбросить bypass-кэш |
|
||||
| `POST` | `/api/route/check` | Симулировать маршрут |
|
||||
| `POST` | `/api/servers/ping` | TCP-пинг до хоста |
|
||||
| `GET` | `/api/logs/stream` | SSE системных логов |
|
||||
| `GET` | `/api/traffic/stream` | SSE трафика |
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── Dockerfile # debian + sing-box + ipset + node
|
||||
├── entrypoint.sh # iptables/ipset setup → запуск node
|
||||
├── docker-compose.gateway.yml
|
||||
├── src/
|
||||
│ ├── server/
|
||||
│ │ ├── index.js # HTTP-сервер, управление sing-box, SSE
|
||||
│ │ ├── singbox.js # генерация конфига sing-box
|
||||
│ │ ├── subscription.js # парсинг подписок (JSON/VLESS/base64)
|
||||
│ │ ├── routeMatcher.js # симулятор маршрутизации
|
||||
│ │ ├── ping.js # TCP-пинг и DNS-resolve
|
||||
│ │ └── config.js # настройки из env
|
||||
│ └── web/
|
||||
│ ├── App.jsx # корневой компонент, глобальный state
|
||||
│ ├── api.js # обёртка fetch для API
|
||||
│ └── components/
|
||||
│ ├── OverviewPage.jsx # дашборд, bypass-toggle
|
||||
│ ├── LogsPage.jsx # трафик + системные логи
|
||||
│ ├── RoutingPage.jsx # кастомные правила
|
||||
│ ├── ServersPage.jsx # подписка и выбор сервера
|
||||
│ ├── SettingsPage.jsx # rule-sets и настройки
|
||||
│ └── RouteChecker.jsx # проверка маршрута
|
||||
└── docs/
|
||||
└── roadmap.md
|
||||
```
|
||||
|
||||
## Ограничения
|
||||
|
||||
- TProxy только IPv4. IPv6 — в roadmap.
|
||||
- DNS-перехват не включён; выдавайте клиентам DNS через DHCP роутера.
|
||||
- Gateway не видит имя процесса на клиентском ПК — правила для игр задаются через домены, CIDR и порты.
|
||||
|
||||
46
docker-compose.client.yml
Normal file
46
docker-compose.client.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
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: ${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
|
||||
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}
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
ALL_PROXY: ""
|
||||
http_proxy: ""
|
||||
https_proxy: ""
|
||||
all_proxy: ""
|
||||
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
|
||||
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||
ports:
|
||||
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||
- "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
|
||||
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:
|
||||
34
docker-compose.gateway.yml
Normal file
34
docker-compose.gateway.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
vpn-proxy-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
||||
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
||||
container_name: vpn-proxy-gateway
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
volumes:
|
||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||
- sing-box-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-data:
|
||||
sing-box-cache:
|
||||
@@ -1,41 +1,22 @@
|
||||
# ==========================================
|
||||
# СЕРВЕРНАЯ КОНФИГУРАЦИЯ (Linux VPS)
|
||||
# ==========================================
|
||||
# Используйте этот файл на удалённом сервере:
|
||||
# docker compose -f docker-compose.server.yml up -d
|
||||
#
|
||||
# network_mode: host решает проблему UDP ASSOCIATE
|
||||
# для SOCKS5 прокси (важно для Discord голоса!)
|
||||
# ==========================================
|
||||
|
||||
version: "3.9"
|
||||
services:
|
||||
sing-proxy:
|
||||
container_name: sing-proxy
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.singbox
|
||||
|
||||
# HOST MODE — контейнер использует сеть хоста напрямую
|
||||
# Это решает проблему UDP ASSOCIATE для SOCKS5
|
||||
# ВАЖНО: работает только на Linux, не на Windows/macOS!
|
||||
vpn-proxy-gateway:
|
||||
image: ${GATEWAY_IMAGE}
|
||||
container_name: vpn-proxy-gateway
|
||||
network_mode: host
|
||||
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Порт веб-интерфейса (по умолчанию 3456)
|
||||
- PORT=${PORT:-3456}
|
||||
# Порт прокси HTTP/SOCKS5 (по умолчанию 8080)
|
||||
- PROXY_PORT=${PROXY_PORT:-8080}
|
||||
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||
- sing-box-cache:/var/lib/sing-box
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
|
||||
# Порты при network_mode: host не нужно пробрасывать,
|
||||
# они автоматически доступны на хосте:
|
||||
# - 3456: Веб-интерфейс (PORT)
|
||||
# - 8080: SOCKS5/HTTP прокси (PROXY_PORT)
|
||||
volumes:
|
||||
vpn-proxy-data:
|
||||
sing-box-cache:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
sing-proxy:
|
||||
container_name: sing-proxy
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.singbox
|
||||
ports:
|
||||
# Веб-интерфейс (можно переопределить: PORT=9090 docker compose up)
|
||||
- "${PORT:-3456}:${PORT:-3456}"
|
||||
# Прокси HTTP/SOCKS5 (можно переопределить: PROXY_PORT=8082 docker compose up)
|
||||
- "${PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
|
||||
environment:
|
||||
- PORT=${PORT:-3456}
|
||||
- PROXY_PORT=${PROXY_PORT:-8080}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
@@ -1,28 +0,0 @@
|
||||
FROM alpine:3.20
|
||||
ARG SINGBOX_VER=1.12.13
|
||||
|
||||
# Устанавливаем зависимости, включая dos2unix для исправления скриптов
|
||||
RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd python3 dos2unix && update-ca-certificates
|
||||
|
||||
# Автоматическое определение архитектуры и установка sing-box
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then SB_ARCH="amd64"; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then SB_ARCH="arm64"; \
|
||||
else SB_ARCH="amd64"; fi && \
|
||||
curl -L -o /tmp/sb.tar.gz https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VER}/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}.tar.gz \
|
||||
&& tar -xf /tmp/sb.tar.gz -C /tmp \
|
||||
&& mv /tmp/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}/sing-box /usr/local/bin/sing-box \
|
||||
&& chmod +x /usr/local/bin/sing-box \
|
||||
&& adduser -D -u 1000 suser
|
||||
|
||||
COPY --chown=suser:suser docker/entrypoint.sh /app/
|
||||
COPY --chown=suser:suser web/ /app/web/
|
||||
|
||||
# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск
|
||||
RUN dos2unix /app/*.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
# Порты по умолчанию (можно переопределить через ENV)
|
||||
# PORT - веб-интерфейс, PROXY_PORT - прокси
|
||||
EXPOSE 3456 8080 9090
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
CONFIG_FILE="/app/data/client.json"
|
||||
SINGBOX_PID=""
|
||||
|
||||
# Порты из ENV (по умолчанию: 3456 для веба, 8080 для прокси)
|
||||
PORT="${PORT:-3456}"
|
||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
start_singbox() {
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
echo "$(date): Starting sing-box..."
|
||||
sing-box run -c "$CONFIG_FILE" &
|
||||
SINGBOX_PID=$!
|
||||
echo "$(date): sing-box started with PID $SINGBOX_PID"
|
||||
else
|
||||
echo "$(date): Config file not found. Use web UI at :$PORT to apply config."
|
||||
SINGBOX_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
stop_singbox() {
|
||||
if [[ -n "$SINGBOX_PID" ]]; then
|
||||
echo "$(date): Stopping sing-box (PID $SINGBOX_PID)..."
|
||||
kill "$SINGBOX_PID" 2>/dev/null || true
|
||||
wait "$SINGBOX_PID" 2>/dev/null || true
|
||||
SINGBOX_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
restart_singbox() {
|
||||
stop_singbox
|
||||
start_singbox
|
||||
}
|
||||
|
||||
start_singbox
|
||||
|
||||
# Start Web UI Server with configurable port
|
||||
echo "$(date): Starting Web UI on port $PORT..."
|
||||
PORT=$PORT PROXY_PORT=$PROXY_PORT python3 /app/web/server.py &
|
||||
WEBUI_PID=$!
|
||||
|
||||
# HTTP Control Server (Simple Netcat loop)
|
||||
# Listens on 9090.
|
||||
# Endpoint: /reload -> Restart sing-box (used by web_server.py after config change)
|
||||
(
|
||||
while true; do
|
||||
# Read the request using nc.
|
||||
REQ=$(echo -e "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" | nc -l -p 9090 -q 1)
|
||||
echo "$(date): Received request on 9090"
|
||||
|
||||
if echo "$REQ" | grep -q "GET /reload"; then
|
||||
echo "$(date): Action: RELOAD (Restart sing-box)"
|
||||
restart_singbox
|
||||
else
|
||||
echo "$(date): Unknown request or ping."
|
||||
fi
|
||||
done
|
||||
) &
|
||||
CONTROL_PID=$!
|
||||
|
||||
# Keep container alive - wait for any background process
|
||||
echo "$(date): Entrypoint ready. Waiting for processes..."
|
||||
|
||||
# Wait indefinitely - if WebUI dies, restart container
|
||||
wait $WEBUI_PID
|
||||
178
docs/DOCKER.md
178
docs/DOCKER.md
@@ -1,178 +0,0 @@
|
||||
# 🐳 Docker — Веб-интерфейс для управления VPN
|
||||
|
||||
> **Это продвинутый способ** установки с красивым веб-интерфейсом. Для большинства пользователей рекомендуется использовать [основной способ через PowerShell](../README.md).
|
||||
|
||||
---
|
||||
|
||||
## 📖 Что это даёт?
|
||||
|
||||
- 🌐 **Веб-интерфейс** — управление через браузер на http://localhost:3456
|
||||
- 📡 **Подписки** — автоматическое получение списка серверов
|
||||
- 🔄 **Переключение серверов** — в один клик
|
||||
- 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Требования
|
||||
|
||||
### Docker Desktop
|
||||
|
||||
1. Скачайте: https://www.docker.com/products/docker-desktop/
|
||||
2. Установите и запустите
|
||||
3. Убедитесь, что иконка 🐳 есть в трее (панель задач)
|
||||
|
||||
> 💡 На Windows может потребоваться WSL2. Docker Desktop предложит его установить автоматически.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Установка
|
||||
|
||||
### Шаг 1: Откройте терминал
|
||||
|
||||
Откройте PowerShell или Командную строку и перейдите в папку проекта:
|
||||
|
||||
```powershell
|
||||
cd путь\к\папке\vpn-proxy
|
||||
```
|
||||
|
||||
### Шаг 2: Соберите контейнер
|
||||
|
||||
```powershell
|
||||
docker compose build
|
||||
```
|
||||
|
||||
Это создаст образ со всеми необходимыми компонентами. Выполняется один раз.
|
||||
|
||||
### Шаг 3: Запустите
|
||||
|
||||
```powershell
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Флаг `-d` запускает контейнер в фоновом режиме.
|
||||
|
||||
### Шаг 4: Откройте веб-интерфейс
|
||||
|
||||
Перейдите в браузере: **http://localhost:3456**
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Использование веб-интерфейса
|
||||
|
||||
### Режим подписки
|
||||
|
||||
1. Вставьте URL подписки в поле "Подписка"
|
||||
2. Нажмите **"Загрузить серверы"**
|
||||
3. Выберите сервер из списка
|
||||
4. Нажмите **"Применить"**
|
||||
|
||||
### Режим VLESS
|
||||
|
||||
1. Перейдите на вкладку "VLESS Ключ"
|
||||
2. Вставьте VLESS-ссылку (`vless://...`)
|
||||
3. Нажмите **"Применить"**
|
||||
|
||||
> 💡 Настройки сохраняются в папке `data/` и восстанавливаются при перезапуске.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Порты
|
||||
|
||||
| Порт | Назначение | URL |
|
||||
|------|------------|-----|
|
||||
| `3456` | Веб-интерфейс | http://localhost:3456 |
|
||||
| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` |
|
||||
| `9090` | API управления (внутренний) | — |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Управление контейнером
|
||||
|
||||
| Действие | Команда |
|
||||
|----------|---------|
|
||||
| Посмотреть статус | `docker ps` |
|
||||
| Посмотреть логи | `docker logs --tail 50 sing-proxy` |
|
||||
| Остановить | `docker compose stop` |
|
||||
| Запустить снова | `docker compose start` |
|
||||
| Перезапустить | `docker compose restart` |
|
||||
| Полностью удалить | `docker compose down` |
|
||||
| Пересобрать | `docker compose up -d --build` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
Если вы обновили код из репозитория:
|
||||
|
||||
```powershell
|
||||
# Остановить текущий контейнер
|
||||
docker compose down
|
||||
|
||||
# Пересобрать с новыми изменениями
|
||||
docker compose build --no-cache
|
||||
|
||||
# Запустить заново
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> 💡 Подписка и настройки сохраняются в папке `data/` и не потеряются.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройка приложений
|
||||
|
||||
### Для VS Code
|
||||
|
||||
```json
|
||||
{
|
||||
"http.proxy": "http://127.0.0.1:8080",
|
||||
"http.proxyStrictSSL": true
|
||||
}
|
||||
```
|
||||
|
||||
### Для браузера
|
||||
|
||||
- **Адрес**: `127.0.0.1`
|
||||
- **Порт**: `8080`
|
||||
- **Тип**: HTTP или SOCKS5
|
||||
|
||||
---
|
||||
|
||||
## ❓ Проблемы и решения
|
||||
|
||||
### Страница localhost:3456 не открывается
|
||||
|
||||
**Причина:** Контейнер не запущен.
|
||||
|
||||
```powershell
|
||||
# Проверьте статус
|
||||
docker ps
|
||||
|
||||
# Если контейнера нет — запустите
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### "Connection refused"
|
||||
|
||||
**Причина:** VPN-ссылка не применена.
|
||||
|
||||
1. Откройте http://localhost:3456
|
||||
2. Примените VLESS-ссылку или загрузите подписку
|
||||
|
||||
### Медленное подключение
|
||||
|
||||
Попробуйте другой сервер в веб-интерфейсе — некоторые серверы могут быть перегружены.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Ограничения Docker на Windows
|
||||
|
||||
- **UDP для Discord:** Docker на Windows/macOS имеет проблемы с UDP ASSOCIATE для SOCKS5. Для Discord рекомендуется использовать [нативную установку](../README.md).
|
||||
|
||||
- **Для полной поддержки UDP** используйте [установку на Linux сервер](SERVER.md) с `network_mode: host`.
|
||||
|
||||
---
|
||||
|
||||
[← Вернуться к основной инструкции](../README.md)
|
||||
278
docs/SERVER.md
278
docs/SERVER.md
@@ -1,278 +0,0 @@
|
||||
# 🌍 Установка на Сервер (Linux VPS)
|
||||
|
||||
> Эта инструкция для установки прокси на удалённый сервер. После установки вы сможете подключаться к нему с любого устройства.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Зачем это нужно?
|
||||
|
||||
- 🌐 **Один прокси для всех устройств** — компьютер, телефон, планшет
|
||||
- 🔒 **Работает 24/7** — не нужно держать компьютер включённым
|
||||
- 📡 **Полная поддержка UDP** — голосовые звонки и игры работают отлично
|
||||
- 🏠 **Доступ из любого места** — дома, на работе, в поездке
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Требования к серверу
|
||||
|
||||
- **ОС:** Ubuntu 20.04+, Debian 11+, или любой современный Linux
|
||||
- **Ресурсы:** Минимум 512 MB RAM, 1 CPU
|
||||
- **Порты:** 3456 (веб-интерфейс), 8080 (прокси)
|
||||
- **Доступ:** SSH подключение
|
||||
|
||||
> 💡 Подойдёт любой VPS за $3-5/месяц от DigitalOcean, Vultr, Hetzner и др.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Установка
|
||||
|
||||
### Шаг 1: Подключитесь к серверу
|
||||
|
||||
Откройте терминал (PowerShell на Windows, Terminal на Mac/Linux):
|
||||
|
||||
```bash
|
||||
ssh root@ваш_сервер_ip
|
||||
```
|
||||
|
||||
Введите пароль когда попросят.
|
||||
|
||||
> 💡 **Совет:** Если вы на Windows и нет ssh команды, используйте PuTTY или Windows Terminal.
|
||||
|
||||
---
|
||||
|
||||
### Шаг 2: Установите Docker
|
||||
|
||||
Если Docker ещё не установлен:
|
||||
|
||||
```bash
|
||||
# Автоматическая установка Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Проверка что Docker работает
|
||||
docker --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 3: Загрузите проект
|
||||
|
||||
**Вариант A: Через Git**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-repo/vpn-proxy.git
|
||||
cd vpn-proxy
|
||||
```
|
||||
|
||||
**Вариант B: Загрузка файлов вручную**
|
||||
|
||||
Если git недоступен, скачайте ZIP архив и распакуйте на сервере.
|
||||
|
||||
---
|
||||
|
||||
### Шаг 4: Запустите контейнер
|
||||
|
||||
> ⚠️ **Важно:** Используйте `docker-compose.server.yml` — он настроен для серверов!
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.server.yml up -d
|
||||
```
|
||||
|
||||
Это запустит контейнер с `network_mode: host`, что решает проблемы с UDP.
|
||||
|
||||
---
|
||||
|
||||
### Шаг 5: Откройте порты в файрволе
|
||||
|
||||
**Для UFW (Ubuntu/Debian):**
|
||||
|
||||
```bash
|
||||
ufw allow 3456/tcp # Веб-интерфейс
|
||||
ufw allow 8080/tcp # Прокси TCP
|
||||
ufw allow 8080/udp # Прокси UDP (для голоса/игр)
|
||||
ufw reload
|
||||
```
|
||||
|
||||
**Для firewalld (CentOS/RHEL):**
|
||||
|
||||
```bash
|
||||
firewall-cmd --permanent --add-port=3456/tcp
|
||||
firewall-cmd --permanent --add-port=8080/tcp
|
||||
firewall-cmd --permanent --add-port=8080/udp
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
**Для iptables:**
|
||||
|
||||
```bash
|
||||
iptables -A INPUT -p tcp --dport 3456 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
|
||||
iptables -A INPUT -p udp --dport 8080 -j ACCEPT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 6: Настройте VPN через веб-интерфейс
|
||||
|
||||
1. Откройте в браузере: `http://ваш_сервер_ip:3456`
|
||||
2. Вставьте VLESS-ссылку или URL подписки
|
||||
3. Нажмите "Применить"
|
||||
|
||||
---
|
||||
|
||||
## ✅ Проверка работы
|
||||
|
||||
На сервере:
|
||||
|
||||
```bash
|
||||
# Проверить что контейнер запущен
|
||||
docker ps
|
||||
|
||||
# Посмотреть логи
|
||||
docker logs --tail 20 sing-proxy
|
||||
```
|
||||
|
||||
С вашего компьютера:
|
||||
|
||||
```bash
|
||||
# Проверить прокси
|
||||
curl -x http://ваш_сервер_ip:8080 https://ipinfo.io/ip
|
||||
```
|
||||
|
||||
Должен показать IP VPN-сервера (не IP вашего VPS).
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Подключение с Windows
|
||||
|
||||
### Настройка в manage.ps1
|
||||
|
||||
При настройке Discord (пункт [2]) вы можете указать адрес удалённого прокси:
|
||||
|
||||
```
|
||||
Введите адрес прокси (IP:порт): ваш_сервер_ip:8080
|
||||
```
|
||||
|
||||
### Настройка в браузере/приложениях
|
||||
|
||||
- **Адрес:** `ваш_сервер_ip`
|
||||
- **Порт:** `8080`
|
||||
- **Тип:** HTTP или SOCKS5
|
||||
|
||||
---
|
||||
|
||||
## 📋 Управление
|
||||
|
||||
| Действие | Команда |
|
||||
|----------|---------|
|
||||
| Посмотреть статус | `docker ps` |
|
||||
| Логи | `docker logs --tail 50 sing-proxy` |
|
||||
| Остановить | `docker compose -f docker-compose.server.yml stop` |
|
||||
| Запустить | `docker compose -f docker-compose.server.yml start` |
|
||||
| Перезапустить | `docker compose -f docker-compose.server.yml restart` |
|
||||
| Удалить | `docker compose -f docker-compose.server.yml down` |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Рекомендации по безопасности
|
||||
|
||||
### 1. Смените стандартные порты
|
||||
|
||||
Отредактируйте `docker-compose.server.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PORT=54321 # Вместо 3456
|
||||
- PROXY_PORT=12345 # Вместо 8080
|
||||
```
|
||||
|
||||
### 2. Ограничьте доступ к веб-интерфейсу
|
||||
|
||||
Если веб-интерфейс нужен только для первоначальной настройки:
|
||||
|
||||
```bash
|
||||
# Закрыть веб-порт после настройки
|
||||
ufw delete allow 3456/tcp
|
||||
```
|
||||
|
||||
### 3. Используйте SSH туннель
|
||||
|
||||
Для безопасного доступа к веб-интерфейсу:
|
||||
|
||||
```bash
|
||||
ssh -L 3456:localhost:3456 root@ваш_сервер_ip
|
||||
```
|
||||
|
||||
Затем откройте http://localhost:3456 в браузере.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
```bash
|
||||
cd vpn-proxy
|
||||
|
||||
# Получить обновления
|
||||
git pull
|
||||
|
||||
# Пересобрать контейнер
|
||||
docker compose -f docker-compose.server.yml down
|
||||
docker compose -f docker-compose.server.yml build --no-cache
|
||||
docker compose -f docker-compose.server.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ Проблемы и решения
|
||||
|
||||
### Порт 3456 не открывается
|
||||
|
||||
**Причина:** Файрвол блокирует подключения.
|
||||
|
||||
**Решение:** Проверьте настройки файрвола, см. Шаг 5.
|
||||
|
||||
### "Permission denied" при запуске Docker
|
||||
|
||||
**Решение:**
|
||||
|
||||
```bash
|
||||
# Добавить пользователя в группу docker
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Перезайти
|
||||
exit
|
||||
ssh root@ваш_сервер_ip
|
||||
```
|
||||
|
||||
### Контейнер постоянно перезапускается
|
||||
|
||||
```bash
|
||||
# Посмотреть логи ошибок
|
||||
docker logs sing-proxy
|
||||
```
|
||||
|
||||
Обычно проблема в неверной VLESS-ссылке.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Изменение портов
|
||||
|
||||
По умолчанию:
|
||||
- **3456** — веб-интерфейс
|
||||
- **8080** — прокси
|
||||
|
||||
Для изменения создайте файл `.env` в папке проекта:
|
||||
|
||||
```env
|
||||
PORT=54321
|
||||
PROXY_PORT=12345
|
||||
```
|
||||
|
||||
И перезапустите:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.server.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← Вернуться к основной инструкции](../README.md)
|
||||
105
docs/roadmap.md
Normal file
105
docs/roadmap.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Roadmap: VPN Proxy rebuild
|
||||
|
||||
## Целевая модель
|
||||
|
||||
Проект должен стать multi-mode системой вокруг `sing-box`:
|
||||
|
||||
| Режим | Назначение | Runtime | Статус |
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
||||
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
|
||||
|
||||
## Gateway mode
|
||||
|
||||
Цель: контейнер, который становится прозрачным gateway для сети.
|
||||
|
||||
Требования:
|
||||
|
||||
- `sing-box` внутри контейнера.
|
||||
- `network_mode: host`.
|
||||
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
|
||||
- TProxy inbound на `7895`.
|
||||
- Mixed HTTP/SOCKS inbound на `8080`.
|
||||
- Web UI на `3456`.
|
||||
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
|
||||
- Пользовательские routing lists управляются из UI.
|
||||
- Генерируется `/etc/sing-box/config.json`.
|
||||
- `sing-box check` перед применением.
|
||||
- Restart `sing-box` после применения.
|
||||
- Idempotent iptables setup.
|
||||
- Cleanup iptables/ip rule/ip route при остановке контейнера.
|
||||
|
||||
Маршрутизация v1:
|
||||
|
||||
- private IP ranges -> `direct`.
|
||||
- пользовательские списки -> `direct`, `vpn` или `block`.
|
||||
- `geoip-ru` -> `direct`.
|
||||
- `geosite-category-ru` -> `direct`.
|
||||
- все остальное -> выбранный VPN outbound.
|
||||
|
||||
Порядок правил:
|
||||
|
||||
1. safety private-direct, чтобы не ломать LAN.
|
||||
2. custom routing lists из UI.
|
||||
3. RU direct rules.
|
||||
4. default VPN outbound.
|
||||
|
||||
Формат пользовательского списка:
|
||||
|
||||
- `name`.
|
||||
- `enabled`.
|
||||
- `outbound`: `direct`, `vpn`, `block`.
|
||||
- `domains`: exact domains.
|
||||
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
|
||||
- `domainKeywords`: keyword matching.
|
||||
- `ipCidrs`: CIDR ranges.
|
||||
- `ports`: TCP/UDP ports.
|
||||
- `networks`: `tcp`, `udp`.
|
||||
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
|
||||
|
||||
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
|
||||
|
||||
Отдельно решить позже:
|
||||
|
||||
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
|
||||
- IPv6 TProxy.
|
||||
- nftables backend.
|
||||
- health checks и smoke diagnostics.
|
||||
- secret storage через Infisical/Vault/env.
|
||||
|
||||
## Desktop proxy mode
|
||||
|
||||
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
|
||||
|
||||
Требования:
|
||||
|
||||
- UI на `3456`.
|
||||
- Mixed inbound на `8080`.
|
||||
- Subscription parser.
|
||||
- Выбор сервера.
|
||||
- Fallback proxy через `urltest`.
|
||||
- Direct mode toggle.
|
||||
- Не требует `NET_ADMIN`.
|
||||
|
||||
## Windows gaming mode
|
||||
|
||||
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
||||
|
||||
Требования:
|
||||
|
||||
- Native `sing-box.exe`.
|
||||
- Scheduled task или Windows service.
|
||||
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
||||
- Управление из PowerShell helper.
|
||||
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
||||
|
||||
## Рабочий порядок
|
||||
|
||||
1. Сделать новый gateway root.
|
||||
2. Реализовать Docker image + entrypoint TProxy lifecycle.
|
||||
3. Реализовать маленький control-server.
|
||||
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
||||
5. Добавить gateway docs/install script.
|
||||
6. Потом переносить desktop-proxy.
|
||||
7. Потом приводить Windows mode к новой архитектуре.
|
||||
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.
|
||||
2190
docs/superpowers/plans/2026-05-21-windows-client.md
Normal file
2190
docs/superpowers/plans/2026-05-21-windows-client.md
Normal file
File diff suppressed because it is too large
Load Diff
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`.
|
||||
229
docs/superpowers/specs/2026-05-21-windows-client-design.md
Normal file
229
docs/superpowers/specs/2026-05-21-windows-client-design.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Windows Client Design
|
||||
|
||||
## Goal
|
||||
|
||||
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.
|
||||
|
||||
## Product Shape
|
||||
|
||||
The Windows mode is script-first and UI-assisted. The installer remains the durable entrypoint because Windows driver/service setup needs administrator rights and must stay easy to debug from PowerShell. The web UI is a local control surface on top of the same scripts, not a separate desktop app in the first version.
|
||||
|
||||
The installer supports two paths:
|
||||
|
||||
- **Full install:** install native `sing-box.exe`, configure a local SOCKS/HTTP proxy on `127.0.0.1:1080`, install WinPacketFilter and ProxiFyre, then route selected Windows apps through the local proxy.
|
||||
- **ProxiFyre only:** install WinPacketFilter and ProxiFyre, then point selected Windows apps to an existing proxy target such as `127.0.0.1:8080`, `192.168.50.111:8080`, or another reachable SOCKS5 endpoint.
|
||||
|
||||
The default UI direction is the approved cleaner mockup: one route status, profiles as the main object, selected profile details on the right, and a short recent activity/log section below. The first screen should answer: what is the active proxy target, whether services are running, and which profiles are currently enabled.
|
||||
|
||||
## User Flows
|
||||
|
||||
### Install
|
||||
|
||||
The user opens PowerShell 7 as Administrator and runs:
|
||||
|
||||
```powershell
|
||||
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex
|
||||
```
|
||||
|
||||
The installer checks administrator rights, PowerShell version, architecture, internet access, and required paths. It installs under `C:\Tools\vpn-proxy-windows` and keeps third-party runtime files in focused subdirectories:
|
||||
|
||||
- `C:\Tools\vpn-proxy-windows\app` for this project checkout or archive.
|
||||
- `C:\Tools\vpn-proxy-windows\runtime\node` for portable Node.js when no suitable Node is installed.
|
||||
- `C:\Tools\vpn-proxy-windows\runtime\sing-box` for `sing-box.exe`, config, and logs.
|
||||
- `C:\Tools\ProxiFyre` for ProxiFyre, matching the legacy script path.
|
||||
|
||||
If the user chooses Full install, the installer asks for a subscription or VLESS link, parses it through the existing subscription logic where possible, lets the user select a server, writes the native `sing-box` config, installs a scheduled task for `sing-box`, and starts it.
|
||||
|
||||
If the user chooses ProxiFyre only, the installer asks for a SOCKS5 proxy target and verifies TCP connectivity before writing ProxiFyre config.
|
||||
|
||||
After setup, the installer starts the local control UI on `http://127.0.0.1:3456` and prints recovery commands:
|
||||
|
||||
```powershell
|
||||
& "C:\Tools\vpn-proxy-windows\manage.ps1"
|
||||
& "C:\Tools\vpn-proxy-windows\manage.ps1" -OpenUi
|
||||
& "C:\Tools\vpn-proxy-windows\manage.ps1" -Status
|
||||
```
|
||||
|
||||
### Profile Management
|
||||
|
||||
Profiles are the central unit. A profile contains a name, enabled flag, proxy target, protocol list, and app items. Supported item types:
|
||||
|
||||
- `process`: process name such as `Discord`, `Update`, or `Vesktop`.
|
||||
- `folder`: folder path; the system scans `.exe` files and converts them to routable entries.
|
||||
- `exe`: explicit executable file path; the system resolves it to the executable name for ProxiFyre and keeps the full path for display and diagnostics.
|
||||
|
||||
Folder and exe entries are intentionally stored as user-facing source items, while the generated ProxiFyre config is derived. This keeps the UI understandable and makes future ProxiFyre/Proxifier adapter changes possible without changing the profile model.
|
||||
|
||||
When a profile changes, the UI marks it as pending. The user applies changes once. Apply regenerates ProxiFyre `app-config.json`, restarts the ProxiFyre service, then writes an activity entry showing what changed.
|
||||
|
||||
### Runtime Operations
|
||||
|
||||
The UI exposes these actions:
|
||||
|
||||
- start, stop, restart `sing-box` when local mode is installed;
|
||||
- start, stop, restart ProxiFyre;
|
||||
- switch a profile between `local-singbox` and an external proxy target;
|
||||
- add process, folder, or exe entries;
|
||||
- scan folder entries again;
|
||||
- copy diagnostics for support/debugging;
|
||||
- open logs.
|
||||
|
||||
The UI does not auto-change global Windows proxy settings. Routing happens only through ProxiFyre profiles.
|
||||
|
||||
## Architecture
|
||||
|
||||
The active project already has a plain Node API server, React/Vite UI, subscription parser, `sing-box` config generator, logs, traffic parsing, and client/gateway modes. Windows mode should reuse those pieces and add a Windows helper boundary.
|
||||
|
||||
### App Mode
|
||||
|
||||
Add `APP_MODE=windows`. In Windows mode:
|
||||
|
||||
- the HTTP server binds to `127.0.0.1`;
|
||||
- the UI uses Windows labels and hides gateway-only TProxy/device controls;
|
||||
- config generation is proxy-only like client mode, but it targets native `sing-box.exe` rather than Docker;
|
||||
- service and driver actions go through the PowerShell helper, not direct Node assumptions.
|
||||
|
||||
### Windows Helper Boundary
|
||||
|
||||
Create a PowerShell helper module that owns privileged Windows operations:
|
||||
|
||||
- install/update `sing-box.exe`;
|
||||
- install/start/stop scheduled tasks;
|
||||
- install/update WinPacketFilter;
|
||||
- install/update ProxiFyre;
|
||||
- write ProxiFyre config;
|
||||
- query service/task status;
|
||||
- read recent log files;
|
||||
- test proxy connectivity;
|
||||
- manage firewall rules for local proxy ports.
|
||||
|
||||
The Node server calls the helper with explicit command names and JSON input/output. The helper returns structured JSON for every operation:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "proxies.apply",
|
||||
"changed": true,
|
||||
"message": "ProxiFyre config applied and service restarted"
|
||||
}
|
||||
```
|
||||
|
||||
Errors use the same shape with `success: false`, `error`, and optional `details`. The UI never parses raw PowerShell text.
|
||||
|
||||
### Data Files
|
||||
|
||||
Windows mode stores state under `C:\Tools\vpn-proxy-windows\data`:
|
||||
|
||||
- `windows-profiles.json` for profile source data.
|
||||
- `proxy-targets.json` for `local-singbox` and external proxy targets.
|
||||
- `windows-state.json` for last applied profile revision and UI status.
|
||||
- `subscription-cache.json` and `state.json` stay compatible with existing subscription/server selection logic.
|
||||
|
||||
Profile shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "discord-vesktop",
|
||||
"name": "Discord + Vesktop",
|
||||
"enabled": true,
|
||||
"proxyTargetId": "local-singbox",
|
||||
"protocols": ["TCP", "UDP"],
|
||||
"items": [
|
||||
{ "type": "process", "value": "Discord" },
|
||||
{ "type": "process", "value": "Update" },
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "%LOCALAPPDATA%\\vesktop",
|
||||
"recursive": true
|
||||
},
|
||||
{
|
||||
"type": "exe",
|
||||
"value": "C:\\Games\\SomeGame\\game.exe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Generated ProxiFyre config is not edited directly. It is derived from enabled profiles and proxy targets, then written to `C:\Tools\ProxiFyre\app-config.json`.
|
||||
|
||||
### API Surface
|
||||
|
||||
Add Windows-specific endpoints:
|
||||
|
||||
- `GET /api/windows/status`: returns install mode, `sing-box` status, ProxiFyre status, active target, pending changes, and recent activity.
|
||||
- `GET /api/windows/profiles`: returns profile source data with resolved executable counts.
|
||||
- `PUT /api/windows/profiles`: saves profiles without applying.
|
||||
- `POST /api/windows/profiles/apply`: generates ProxiFyre config and restarts service.
|
||||
- `POST /api/windows/profiles/scan`: resolves folder and exe entries for preview.
|
||||
- `GET /api/windows/targets`: returns `local-singbox` and external proxy targets.
|
||||
- `PUT /api/windows/targets`: saves external proxy targets after validation.
|
||||
- `POST /api/windows/service`: start, stop, or restart `sing-box`, ProxiFyre, or the UI service.
|
||||
- `GET /api/windows/logs`: returns recent helper, `sing-box`, and ProxiFyre logs.
|
||||
|
||||
Existing generic endpoints for subscription fetch, server selection, apply, logs, and config validation should be reused where the behavior matches Windows mode.
|
||||
|
||||
## UI Design
|
||||
|
||||
The approved direction is a restrained operational UI:
|
||||
|
||||
- top bar: product name, restart/stop/add profile actions;
|
||||
- left nav: Overview, Profiles, Targets, Logs, Settings;
|
||||
- main status panel: one sentence describing the active route, plus a compact route line such as `Selected apps -> ProxiFyre -> sing-box -> VPN`;
|
||||
- main workspace: profile list on the left, selected profile details on the right;
|
||||
- profile details: target selector, add process/folder/exe input, resolved items list, save/apply controls;
|
||||
- activity panel: recent traffic/service events, not a full noisy log dump.
|
||||
|
||||
Avoid duplicate status blocks. Avoid showing raw implementation concepts like WinPacketFilter unless the user is in diagnostics/settings. The primary terms should be `Profile`, `Proxy target`, `Local sing-box`, `Existing proxy`, `App/folder/exe`, and `Apply changes`.
|
||||
|
||||
## Safety And Constraints
|
||||
|
||||
The UI binds only to `127.0.0.1`. Windows actions that require elevation stay in the installer/helper path. The installer must not delete existing `C:\Tools\vpn-proxy` legacy folders without confirmation.
|
||||
|
||||
Folder and exe routing needs a clear diagnostic note: ProxiFyre routing ultimately depends on what the installed ProxiFyre version accepts. The first implementation should resolve folders and exe paths to executable names for compatibility, while preserving full paths in profile data and diagnostics. If direct path matching is verified in ProxiFyre, the adapter can emit full paths without changing the UI model.
|
||||
|
||||
The installer should be idempotent:
|
||||
|
||||
- re-running it updates project files;
|
||||
- existing subscriptions and profiles are preserved unless the user chooses reset;
|
||||
- existing ProxiFyre config is backed up before overwrite;
|
||||
- failed applies leave the previous generated config available for rollback.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Use focused tests for pure logic:
|
||||
|
||||
- profile normalization;
|
||||
- folder/exe item resolution;
|
||||
- ProxiFyre config generation;
|
||||
- proxy target validation;
|
||||
- Windows helper JSON command contract;
|
||||
- `APP_MODE=windows` public state and config generation.
|
||||
|
||||
Use manual Windows verification for privileged operations:
|
||||
|
||||
- fresh Full install;
|
||||
- fresh ProxiFyre-only install;
|
||||
- re-run installer over existing install;
|
||||
- add process profile;
|
||||
- add folder profile;
|
||||
- add explicit exe profile;
|
||||
- switch a profile from local sing-box to external proxy;
|
||||
- restart ProxiFyre and verify service status;
|
||||
- copy diagnostics after a failed proxy target check.
|
||||
|
||||
Local non-Windows development should still run `npm test` and `npm run build`. Windows-only helper commands should have dry-run or mockable modes so logic can be tested without installing drivers on the development machine.
|
||||
|
||||
## Non-Goals For First Version
|
||||
|
||||
- No Electron or Tauri wrapper.
|
||||
- No global Windows system proxy changes.
|
||||
- No transparent routing without ProxiFyre.
|
||||
- No remote multi-device management.
|
||||
- No automatic uninstall of unrelated WinPacketFilter users.
|
||||
- No Proxifier support until ProxiFyre behavior is stable.
|
||||
|
||||
## Implementation Defaults
|
||||
|
||||
- The UI server runs when opened by `manage.ps1 -OpenUi` in the first version. An at-logon scheduled UI task can be added later after the helper and UI are stable.
|
||||
- Full install uses portable Node/npm when the machine has no suitable Node.js. The installer builds the React UI locally for MVP; a prebuilt release artifact can replace that later without changing user-facing behavior.
|
||||
- ProxiFyre generation emits process names in the first version for compatibility. Full folder and exe paths remain in profile data and diagnostics; the adapter can start emitting full paths later if ProxiFyre path matching is verified.
|
||||
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
|
||||
124
entrypoint.sh
Normal file
124
entrypoint.sh
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TPROXY_PORT="${TPROXY_PORT:-7895}"
|
||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
|
||||
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
|
||||
# TTL записи в ipset (секунды). По умолчанию 1 час.
|
||||
DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}"
|
||||
# Direct bypass cache выключен по умолчанию, потому что он обходит global rules.
|
||||
DIRECT_BYPASS_CACHE="${DIRECT_BYPASS_CACHE:-false}"
|
||||
|
||||
log() {
|
||||
printf '[gateway-entrypoint] %s\n' "$*"
|
||||
}
|
||||
|
||||
ipt() {
|
||||
iptables -w "$@"
|
||||
}
|
||||
|
||||
cleanup_proxy_firewall() {
|
||||
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||
ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||
ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cleanup_tproxy() {
|
||||
log "cleanup tproxy rules"
|
||||
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||
# ipset не чистим при завершении — TTL сам истечёт
|
||||
}
|
||||
|
||||
setup_direct_bypass_set() {
|
||||
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
|
||||
export DIRECT_BYPASS_CACHE
|
||||
return
|
||||
fi
|
||||
|
||||
log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)"
|
||||
# Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи)
|
||||
ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true
|
||||
# Экспортируем имя для использования в Node.js через env
|
||||
export DIRECT_BYPASS_SET DIRECT_BYPASS_TTL DIRECT_BYPASS_CACHE
|
||||
}
|
||||
|
||||
setup_proxy_firewall() {
|
||||
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "setup proxy firewall for :${PROXY_PORT} (${PROXY_ALLOWED_CIDRS})"
|
||||
cleanup_proxy_firewall
|
||||
|
||||
ipt -N "$PROXY_INPUT_CHAIN"
|
||||
for cidr in $PROXY_ALLOWED_CIDRS; do
|
||||
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
|
||||
done
|
||||
ipt -A "$PROXY_INPUT_CHAIN" -j DROP
|
||||
ipt -I INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||
}
|
||||
|
||||
setup_tproxy() {
|
||||
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
||||
cleanup_tproxy
|
||||
|
||||
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||
|
||||
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||
|
||||
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
||||
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
|
||||
# Включайте только если готовы к тому, что global rules для этих dst IP не будут проверяться.
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN
|
||||
fi
|
||||
|
||||
for cidr in $BYPASS_CIDRS; do
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||
done
|
||||
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
||||
}
|
||||
|
||||
setup_direct_bypass_set
|
||||
setup_tproxy
|
||||
setup_proxy_firewall
|
||||
|
||||
node /app/src/server/index.js &
|
||||
APP_PID=$!
|
||||
|
||||
shutdown() {
|
||||
log "shutdown requested"
|
||||
kill "$APP_PID" 2>/dev/null || true
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
cleanup_proxy_firewall
|
||||
cleanup_tproxy
|
||||
}
|
||||
|
||||
trap 'shutdown; exit 0' SIGTERM SIGINT
|
||||
|
||||
wait "$APP_PID"
|
||||
STATUS=$?
|
||||
cleanup_proxy_firewall
|
||||
cleanup_tproxy
|
||||
exit "$STATUS"
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VPN Proxy Gateway</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/web/App.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
104
install.ps1
104
install.ps1
@@ -1,104 +0,0 @@
|
||||
# ==========================================
|
||||
# 🚀 VPN PROXY INSTALLER
|
||||
# ==========================================
|
||||
# This script automatically downloads and installs VPN Proxy
|
||||
# Usage:
|
||||
# iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
|
||||
|
||||
# Enable UTF-8 for emoji support
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- 1. Check Admin Rights ---
|
||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) {
|
||||
Write-Warning "⚠️ Administrator rights required!"
|
||||
Write-Host "🔄 Restarting script as Administrator..." -ForegroundColor Cyan
|
||||
|
||||
# Save script to temp file if running from memory (iex)
|
||||
if ($MyInvocation.MyCommand.CommandType -eq 'Script') {
|
||||
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs
|
||||
}
|
||||
else {
|
||||
# If running via IEX, we cannot simple restart the file.
|
||||
# We ask user to run terminal as admin.
|
||||
Write-Error "Please run PowerShell as Administrator and try again."
|
||||
}
|
||||
exit
|
||||
}
|
||||
|
||||
# --- 2. Settings ---
|
||||
$InstallRoot = "C:\Tools"
|
||||
$InstallDir = "$InstallRoot\vpn-proxy"
|
||||
# Exact link provided by user
|
||||
$ZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip"
|
||||
$TempZip = "$env:TEMP\vpn-proxy-install.zip"
|
||||
|
||||
Write-Host "🚀 Starting VPN Proxy installation..." -ForegroundColor Green
|
||||
Write-Host "📂 Install path: $InstallDir" -ForegroundColor Gray
|
||||
|
||||
# Move to temp folder to avoid blocking deletion if we are already in C:\Tools\vpn-proxy
|
||||
Set-Location $env:TEMP
|
||||
|
||||
# --- 3. Prepare Directory ---
|
||||
if (-not (Test-Path $InstallRoot)) {
|
||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- 4. Downloading ---
|
||||
Write-Host "⬇️ Downloading update archive..." -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-WebRequest -Uri $ZipUrl -OutFile $TempZip
|
||||
}
|
||||
catch {
|
||||
Write-Error "❌ Failed to download from $ZipUrl`nCheck your internet connection."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 5. Extracting ---
|
||||
Write-Host "📦 Extracting..." -ForegroundColor Cyan
|
||||
|
||||
# If folder exists, delete old one
|
||||
if (Test-Path $InstallDir) {
|
||||
try {
|
||||
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Warning "⚠️ Failed to delete old folder $InstallDir"
|
||||
Write-Warning " Error: $($_.Exception.Message)"
|
||||
Write-Warning " Make sure files are not open in other programs and you are not inside this folder."
|
||||
|
||||
$retry = Read-Host " Press Enter to try again (or Ctrl+C to cancel)"
|
||||
try {
|
||||
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Error "❌ Still failed to delete folder. Installation aborted."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Expand-Archive -Path $TempZip -DestinationPath $InstallRoot -Force
|
||||
|
||||
# Archives usually extract to vpn-proxy-master or vpn-proxy-main
|
||||
# We need to rename it to vpn-proxy
|
||||
$ExtractedFolder = Get-ChildItem -Path $InstallRoot -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1
|
||||
|
||||
if ($ExtractedFolder) {
|
||||
Rename-Item -Path $ExtractedFolder.FullName -NewName "vpn-proxy" -Force
|
||||
}
|
||||
|
||||
# Remove temp archive
|
||||
Remove-Item $TempZip -Force
|
||||
|
||||
if (-not (Test-Path "$InstallDir\manage.ps1")) {
|
||||
Write-Error "❌ Installation error: manage.ps1 not found in $InstallDir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 6. Finish ---
|
||||
Write-Host "✅ Installation complete!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "To start the control menu, run:" -ForegroundColor Cyan
|
||||
Write-Host "& `"$InstallDir\manage.ps1`"" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
94
manage.ps1
94
manage.ps1
@@ -1,94 +0,0 @@
|
||||
# ==========================================
|
||||
# 🚀 VPN PROXY CONTROL CENTER (WINDOWS)
|
||||
# ==========================================
|
||||
# Главный скрипт управления. Запускать от имени Администратора.
|
||||
# Использование: .\manage.ps1 [-Debug]
|
||||
|
||||
param([switch]$Debug)
|
||||
|
||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||
$LibDir = "$ScriptDir\scripts\lib"
|
||||
|
||||
# Проверка библиотек
|
||||
if (!(Test-Path "$LibDir\Common.ps1")) {
|
||||
Write-Host "❌ Ошибка: Не найдены библиотеки в $LibDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
. "$LibDir\Common.ps1"
|
||||
. "$LibDir\System.ps1"
|
||||
|
||||
# Установка режима отладки
|
||||
if ($Debug) {
|
||||
Set-DebugMode -Enabled $true
|
||||
}
|
||||
|
||||
Ensure-Admin
|
||||
|
||||
while ($true) {
|
||||
Write-Header "VPN PROXY CONTROL CENTER" -ClearScreen
|
||||
|
||||
# --- СБОР СТАТУСОВ ---
|
||||
|
||||
# 1. Native Sing-box
|
||||
$sbStatus = Get-TaskStatus -Name "SingBoxProxy"
|
||||
$sbStr = if ($sbStatus -eq "Running") { "РАБОТАЕТ" } else { "ОСТАНОВЛЕН" }
|
||||
$sbColor = if ($sbStatus -eq "Running") { "Green" } else { "Yellow" }
|
||||
if (!$sbStatus) { $sbStr = "НЕ УСТАНОВЛЕН"; $sbColor = "Gray" }
|
||||
|
||||
# 2. Discord Proxy
|
||||
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
|
||||
$discStr = if ($discSvc.Status -eq 'Running') { "АКТИВЕН" } else { "НЕ АКТИВЕН" }
|
||||
$discColor = if ($discSvc.Status -eq 'Running') { "Green" } else { "Gray" }
|
||||
|
||||
# --- ОТРИСОВКА МЕНЮ ---
|
||||
|
||||
Write-Host " [1] 📦 VPN Клиент (Sing-box)" -NoNewline -ForegroundColor White
|
||||
Write-Host " [$sbStr]" -ForegroundColor $sbColor
|
||||
Write-Host " Основной способ. Поддерживает UDP и игры." -ForegroundColor Gray
|
||||
|
||||
# Показываем информацию о подключении если sing-box работает
|
||||
if ($sbStatus -eq "Running") {
|
||||
$LocalProxyPort = 1080
|
||||
. "$LibDir\Net.ps1"
|
||||
$ips = Get-LocalIPs
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " 📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ" -ForegroundColor Cyan
|
||||
Write-Host " ─────────────────────────────" -ForegroundColor DarkGray
|
||||
Write-Host " Локально: " -NoNewline -ForegroundColor Gray
|
||||
Write-Host "127.0.0.1:$LocalProxyPort" -ForegroundColor Green
|
||||
|
||||
if ($ips) {
|
||||
Write-Host " Из сети:" -ForegroundColor Gray
|
||||
foreach ($ip in $ips) {
|
||||
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host " [2] 🎮 Настройка Discord/Vesktop" -NoNewline -ForegroundColor White
|
||||
Write-Host " [$discStr]" -ForegroundColor $discColor
|
||||
Write-Host " Маршрутизация приложений через прокси." -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Write-Host " ---------------------------------------" -ForegroundColor DarkGray
|
||||
|
||||
|
||||
Write-Host " [3] 🔄 Обновить статус" -ForegroundColor White
|
||||
Write-Host " [U] ❌ Удалить всё (Uninstall)" -ForegroundColor Red
|
||||
Write-Host " [q] Выход" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
$choice = Read-Host "👉 Ваш выбор"
|
||||
|
||||
switch ($choice) {
|
||||
"1" { & "$ScriptDir\scripts\setup-singbox.ps1" }
|
||||
"2" { & "$ScriptDir\scripts\setup-discord.ps1" }
|
||||
"3" { continue }
|
||||
"u" { & "$ScriptDir\scripts\uninstall-all.ps1" }
|
||||
"q" { exit }
|
||||
}
|
||||
}
|
||||
1726
package-lock.json
generated
Normal file
1726
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "vpn-proxy-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"test": "node --test",
|
||||
"start": "node src/server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
112
scripts/build-on-107-deploy-111.sh
Executable file
112
scripts/build-on-107-deploy-111.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_HOST="${BUILD_HOST:-107}"
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-111}"
|
||||
BUILD_PATH="${BUILD_PATH:-/opt/vpn-proxy-build}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-vpn-proxy-gateway}"
|
||||
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
||||
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||
AUTO_BUILD_RUNTIME_BASE="${AUTO_BUILD_RUNTIME_BASE:-true}"
|
||||
SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}"
|
||||
|
||||
echo "Build host: ${BUILD_HOST}"
|
||||
echo "Deploy host: ${DEPLOY_HOST}"
|
||||
echo "Image: ${GATEWAY_IMAGE}"
|
||||
echo "Base image: ${BASE_IMAGE}"
|
||||
echo "Runtime base source: ${RUNTIME_BASE_SOURCE_IMAGE}"
|
||||
|
||||
ensure_known_host() {
|
||||
local host="$1"
|
||||
if [ "${host}" = "local" ]; then return 0; fi
|
||||
local scan_host="${host#*@}"
|
||||
scan_host="${scan_host%%:*}"
|
||||
mkdir -p "${HOME}/.ssh"
|
||||
chmod 700 "${HOME}/.ssh"
|
||||
if ! ssh-keygen -F "${scan_host}" >/dev/null 2>&1; then
|
||||
ssh-keyscan -H "${scan_host}" >> "${HOME}/.ssh/known_hosts"
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
ssh \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
|
||||
-o ServerAliveInterval=15 \
|
||||
-o ServerAliveCountMax=4 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
echo "Syncing source to ${BUILD_HOST}:${BUILD_PATH}"
|
||||
if [ "${BUILD_HOST}" = "local" ]; then
|
||||
BUILD_PATH="$(pwd)"
|
||||
echo "Using local source at ${BUILD_PATH}"
|
||||
else
|
||||
ensure_known_host "${BUILD_HOST}"
|
||||
ssh_cmd "${BUILD_HOST}" "mkdir -p '${BUILD_PATH}'"
|
||||
rsync -az --delete \
|
||||
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||
--exclude '.git' \
|
||||
--exclude '.vpn-proxy' \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'dist' \
|
||||
./ "${BUILD_HOST}:${BUILD_PATH}/"
|
||||
fi
|
||||
|
||||
echo "Building image on ${BUILD_HOST}"
|
||||
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
|
||||
if [ "${BUILD_HOST}" = "local" ]; then
|
||||
bash -lc "${BUILD_COMMAND}"
|
||||
else
|
||||
ensure_known_host "${BUILD_HOST}"
|
||||
ssh_cmd "${BUILD_HOST}" "${BUILD_COMMAND}"
|
||||
fi
|
||||
|
||||
echo "Loading image into ${DEPLOY_HOST}"
|
||||
if [ "${BUILD_HOST}" = "local" ] && [ "${DEPLOY_HOST}" = "local" ]; then
|
||||
docker image inspect "${GATEWAY_IMAGE}" >/dev/null
|
||||
elif [ "${BUILD_HOST}" = "local" ]; then
|
||||
ensure_known_host "${DEPLOY_HOST}"
|
||||
echo "Checking SSH access to ${DEPLOY_HOST}"
|
||||
ssh_cmd "${DEPLOY_HOST}" "true"
|
||||
echo "Transferring image to ${DEPLOY_HOST}"
|
||||
docker save "${GATEWAY_IMAGE}" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||
elif [ "${DEPLOY_HOST}" = "local" ]; then
|
||||
ensure_known_host "${BUILD_HOST}"
|
||||
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | docker load
|
||||
else
|
||||
ensure_known_host "${BUILD_HOST}"
|
||||
ensure_known_host "${DEPLOY_HOST}"
|
||||
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||
fi
|
||||
|
||||
echo "Copying deploy script to ${DEPLOY_HOST}:${DEPLOY_PATH}"
|
||||
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||
mkdir -p "${DEPLOY_PATH}"
|
||||
cp scripts/deploy-gateway.sh "${DEPLOY_PATH}/deploy-gateway.sh"
|
||||
else
|
||||
ensure_known_host "${DEPLOY_HOST}"
|
||||
ssh_cmd "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||
rsync -az \
|
||||
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||
scripts/deploy-gateway.sh "${DEPLOY_HOST}:${DEPLOY_PATH}/deploy-gateway.sh"
|
||||
fi
|
||||
|
||||
echo "Starting gateway on ${DEPLOY_HOST}"
|
||||
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||
cd "${DEPLOY_PATH}"
|
||||
chmod +x ./deploy-gateway.sh
|
||||
DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" PULL_IMAGE=false ./deploy-gateway.sh
|
||||
else
|
||||
ensure_known_host "${DEPLOY_HOST}"
|
||||
ssh_cmd "${DEPLOY_HOST}" \
|
||||
"cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' PULL_IMAGE=false ./deploy-gateway.sh"
|
||||
fi
|
||||
33
scripts/build-runtime-base.sh
Executable file
33
scripts/build-runtime-base.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
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}" \
|
||||
--build-arg http_proxy="${HTTP_PROXY}" \
|
||||
--build-arg https_proxy="${HTTPS_PROXY}" \
|
||||
--build-arg no_proxy="${NO_PROXY}" \
|
||||
-f Dockerfile.runtime-base \
|
||||
-t "${RUNTIME_BASE_IMAGE}" \
|
||||
.
|
||||
71
scripts/deploy-gateway.sh
Normal file
71
scripts/deploy-gateway.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||
GATEWAY_IMAGE="${GATEWAY_IMAGE:?GATEWAY_IMAGE is required}"
|
||||
PULL_IMAGE="${PULL_IMAGE:-true}"
|
||||
|
||||
echo "Preparing deploy directory: ${DEPLOY_PATH}"
|
||||
mkdir -p "${DEPLOY_PATH}"
|
||||
|
||||
cat > "${DEPLOY_PATH}/docker-compose.server.yml" <<EOF
|
||||
services:
|
||||
vpn-proxy-gateway:
|
||||
image: ${GATEWAY_IMAGE}
|
||||
container_name: vpn-proxy-gateway
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
volumes:
|
||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||
- sing-box-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-data:
|
||||
sing-box-cache:
|
||||
EOF
|
||||
|
||||
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
|
||||
cat > "${DEPLOY_PATH}/.env" <<'EOF'
|
||||
PORT=3456
|
||||
PROXY_PORT=8080
|
||||
PROXY_BIND_IP=0.0.0.0
|
||||
TPROXY_PORT=7895
|
||||
TPROXY_MARK=1
|
||||
TPROXY_TABLE=100
|
||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||
ROUTING_RU_DIRECT=true
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
|
||||
else
|
||||
echo "Preserving existing .env"
|
||||
fi
|
||||
|
||||
cd "${DEPLOY_PATH}"
|
||||
|
||||
echo "Pulling image: ${GATEWAY_IMAGE}"
|
||||
if [ "${PULL_IMAGE}" = "true" ]; then
|
||||
docker compose -f docker-compose.server.yml pull
|
||||
else
|
||||
echo "Skipping image pull; using local image ${GATEWAY_IMAGE}"
|
||||
fi
|
||||
|
||||
echo "Starting gateway..."
|
||||
docker compose -f docker-compose.server.yml up -d
|
||||
|
||||
echo "Current container:"
|
||||
docker ps --filter "name=vpn-proxy-gateway"
|
||||
304
scripts/install-macos-client.sh
Executable file
304
scripts/install-macos-client.sh
Executable file
@@ -0,0 +1,304 @@
|
||||
#!/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"
|
||||
DEFAULT_PROXY_PORT="8080"
|
||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
port_range_end() {
|
||||
local start="$1"
|
||||
local end="$((start + 10))"
|
||||
if [ "$end" -gt 65535 ]; then
|
||||
end=65535
|
||||
fi
|
||||
printf '%s\n' "$end"
|
||||
}
|
||||
|
||||
published_port_conflicts() {
|
||||
local port="$1"
|
||||
local line
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
case "$line" in
|
||||
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
||||
*) printf '%s\n' "$line" ;;
|
||||
esac
|
||||
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
||||
}
|
||||
|
||||
proxy_port_conflicts() {
|
||||
local start="$1"
|
||||
local end
|
||||
local port
|
||||
local conflicts
|
||||
|
||||
end="$(port_range_end "$start")"
|
||||
for port in $(seq "$start" "$end"); do
|
||||
conflicts="$(published_port_conflicts "$port")"
|
||||
if [ -n "$conflicts" ]; then
|
||||
printf 'port %s: %s\n' "$port" "$conflicts"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
assert_proxy_port_available() {
|
||||
local port="$1"
|
||||
local conflicts
|
||||
|
||||
conflicts="$(proxy_port_conflicts "$port")"
|
||||
if [ -z "$conflicts" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '[vpn-proxy-client] proxy port range %s-%s is already used:\n%s\n' \
|
||||
"$port" "$(port_range_end "$port")" "$conflicts" >&2
|
||||
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
||||
}
|
||||
|
||||
assert_single_port_available() {
|
||||
local label="$1"
|
||||
local port="$2"
|
||||
local conflicts
|
||||
|
||||
conflicts="$(published_port_conflicts "$port")"
|
||||
if [ -z "$conflicts" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \
|
||||
"$label" "$port" "$conflicts" >&2
|
||||
die "choose another ${label} port or stop the conflicting container"
|
||||
}
|
||||
|
||||
first_free_port() {
|
||||
local start="$1"
|
||||
local port
|
||||
|
||||
for port in $(seq "$start" 65535); do
|
||||
if [ -z "$(published_port_conflicts "$port")" ]; then
|
||||
printf '%s\n' "$port"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
choose_ui_port() {
|
||||
local value="$1"
|
||||
local suggested
|
||||
|
||||
if ! is_valid_port "$value"; then
|
||||
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
|
||||
fi
|
||||
|
||||
if [ -z "$(published_port_conflicts "$value")" ]; then
|
||||
printf '%s\n' "$value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
|
||||
assert_single_port_available "UI" "$value"
|
||||
fi
|
||||
|
||||
suggested="$(first_free_port "$((value + 1))" || true)"
|
||||
suggested="${suggested:-3457}"
|
||||
while true; do
|
||||
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
|
||||
IFS= read -r value </dev/tty || value=""
|
||||
value="${value:-$suggested}"
|
||||
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
|
||||
printf '%s\n' "$value"
|
||||
return 0
|
||||
fi
|
||||
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
|
||||
done
|
||||
}
|
||||
|
||||
assert_ui_outside_proxy_range() {
|
||||
if [ "$UI_PORT" -ge "$PROXY_PORT" ] && [ "$UI_PORT" -le "$PROXY_PORT_END" ]; then
|
||||
die "UI port ${UI_PORT} overlaps proxy port range ${PROXY_PORT}-${PROXY_PORT_END}"
|
||||
fi
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
PROXY_PORT="$(ask_proxy_port)"
|
||||
assert_proxy_port_available "$PROXY_PORT"
|
||||
PROXY_PORT_END="$(port_range_end "$PROXY_PORT")"
|
||||
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
|
||||
UI_PORT="${UI_PORT:-3456}"
|
||||
UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||
assert_ui_outside_proxy_range
|
||||
|
||||
set_env_value APP_MODE client
|
||||
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||
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 "UI port: http://127.0.0.1:${UI_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
|
||||
|
||||
VPN Proxy Client is running.
|
||||
|
||||
UI:
|
||||
http://127.0.0.1:${UI_PORT}
|
||||
|
||||
Proxy:
|
||||
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
|
||||
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 ${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
|
||||
networksetup -setsecurewebproxystate Wi-Fi off
|
||||
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||
|
||||
EOF
|
||||
@@ -1,118 +0,0 @@
|
||||
# ==========================================
|
||||
# 🛠️ COMMON UTILS
|
||||
# ==========================================
|
||||
|
||||
# --- ГЛОБАЛЬНЫЕ НАСТРОЙКИ ---
|
||||
|
||||
# Режим отладки (передаётся через -Debug)
|
||||
if (-not (Test-Path variable:script:DebugMode)) {
|
||||
$script:DebugMode = $false
|
||||
}
|
||||
|
||||
function Set-DebugMode {
|
||||
param([bool]$Enabled)
|
||||
$script:DebugMode = $Enabled
|
||||
if ($Enabled) {
|
||||
Write-Host " 🔧 Debug режим включён" -ForegroundColor Magenta
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DebugMode {
|
||||
return $script:DebugMode
|
||||
}
|
||||
|
||||
# --- ЦВЕТА И ВЫВОД ---
|
||||
|
||||
function Write-Step { param($msg) Write-Host "`n📦 $msg" -ForegroundColor Cyan }
|
||||
function Write-Success { param($msg) Write-Host " ✅ $msg" -ForegroundColor Green }
|
||||
function Write-Warning { param($msg) Write-Host " ⚠️ $msg" -ForegroundColor Yellow }
|
||||
function Write-Error { param($msg) Write-Host " ❌ $msg" -ForegroundColor Red }
|
||||
function Write-Info { param($msg) Write-Host " ℹ️ $msg" -ForegroundColor Gray }
|
||||
|
||||
function Write-DebugLog {
|
||||
param($msg)
|
||||
if ($script:DebugMode) {
|
||||
Write-Host " [DEBUG] $msg" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Header {
|
||||
param($Title, [switch]$ClearScreen)
|
||||
|
||||
if ($ClearScreen -and -not $script:DebugMode) {
|
||||
Clear-Host
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " $Title" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# --- ЗАПУСК КОМАНД ---
|
||||
|
||||
function Invoke-Silent {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string]$Arguments,
|
||||
[switch]$Wait
|
||||
)
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $FilePath
|
||||
$psi.Arguments = $Arguments
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
|
||||
if (-not $script:DebugMode) {
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
}
|
||||
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
|
||||
if ($Wait) {
|
||||
$process.WaitForExit()
|
||||
return $process.ExitCode
|
||||
}
|
||||
|
||||
return $process
|
||||
}
|
||||
|
||||
# --- ПОЛЕЗНЫЕ ФУНКЦИИ ---
|
||||
|
||||
function Get-ScriptDirectory {
|
||||
if ($PSScriptRoot) { return $PSScriptRoot }
|
||||
return Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
}
|
||||
|
||||
function Ensure-Admin {
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
|
||||
if (-not $isAdmin) {
|
||||
Write-Host "⛔ Требуются права АДМИНИСТРАТОРА!" -ForegroundColor Red
|
||||
Write-Host " Пожалуйста, запустите скрипт от имени администратора." -ForegroundColor Gray
|
||||
Start-Sleep -Seconds 3
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
function Show-Menu {
|
||||
param(
|
||||
[string]$Title,
|
||||
[System.Collections.Specialized.OrderedDictionary]$Options,
|
||||
[string]$Prompt = "👉 Ваш выбор"
|
||||
)
|
||||
|
||||
if ($Title) {
|
||||
Write-Host "`n$Title" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$keys = $Options.Keys
|
||||
foreach ($key in $keys) {
|
||||
Write-Host " [$key] $($Options[$key])" -ForegroundColor White
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
return Read-Host "$Prompt"
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
# ==========================================
|
||||
# 🌐 NET UTILS
|
||||
# ==========================================
|
||||
|
||||
# --- CONFIG ---
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
# --- ФУНКЦИИ ---
|
||||
|
||||
$script:HwidFile = "C:\Tools\sing-box\hwid"
|
||||
$script:AppName = "VPN-Proxy-Control by Dokril"
|
||||
|
||||
function Get-HWID {
|
||||
# Генерация или чтение HWID из файла
|
||||
if (Test-Path $script:HwidFile) {
|
||||
return (Get-Content $script:HwidFile -Raw).Trim()
|
||||
}
|
||||
|
||||
# Генерируем новый HWID
|
||||
$hwid = [Guid]::NewGuid().ToString("N").Substring(0, 16)
|
||||
|
||||
# Сохраняем
|
||||
$dir = Split-Path $script:HwidFile -Parent
|
||||
if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
Set-Content -Path $script:HwidFile -Value $hwid
|
||||
|
||||
return $hwid
|
||||
}
|
||||
|
||||
function Get-SubscriptionHeaders {
|
||||
# Формируем заголовки как в server.py
|
||||
$osName = "windows"
|
||||
$osVersion = [Environment]::OSVersion.Version.ToString()
|
||||
|
||||
return @{
|
||||
"User-Agent" = "singbox"
|
||||
"x-hwid" = (Get-HWID)
|
||||
"x-device-os" = $osName
|
||||
"x-ver-os" = $osVersion
|
||||
"x-device-model" = $script:AppName
|
||||
}
|
||||
}
|
||||
|
||||
function Download-File {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$Destination,
|
||||
[string]$UserAgent = "VPN-Proxy-Installer"
|
||||
)
|
||||
|
||||
try {
|
||||
$req = [System.Net.HttpWebRequest]::Create($Url)
|
||||
$req.UserAgent = $UserAgent
|
||||
$resp = $req.GetResponse()
|
||||
|
||||
$stream = $resp.GetResponseStream()
|
||||
$fs = [System.IO.File]::Create($Destination)
|
||||
$msgLen = $resp.ContentLength
|
||||
|
||||
$buffer = New-Object byte[] 10240
|
||||
$count = 0
|
||||
$total = 0
|
||||
|
||||
do {
|
||||
$count = $stream.Read($buffer, 0, $buffer.Length)
|
||||
$fs.Write($buffer, 0, $count)
|
||||
$total += $count
|
||||
# Можно добавить прогресс бар, но пока просто качаем
|
||||
} while ($count -gt 0)
|
||||
|
||||
$fs.Close()
|
||||
$stream.Close()
|
||||
$resp.Close()
|
||||
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Error "Ошибка скачивания: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SubscriptionData {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$UserAgent = "singbox",
|
||||
$Headers = @{}
|
||||
)
|
||||
|
||||
Write-Info "Загружаю подписку..."
|
||||
|
||||
$rawContent = $null
|
||||
$userInfo = @{}
|
||||
|
||||
# 1. Получаем ответ
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $Url -Headers $Headers -TimeoutSec 15 -UseBasicParsing
|
||||
$rawContent = $response.Content
|
||||
|
||||
# Парсим subscription-userinfo header
|
||||
$userInfoHeader = $response.Headers["subscription-userinfo"]
|
||||
if ($userInfoHeader) {
|
||||
$parts = $userInfoHeader -split ";"
|
||||
foreach ($part in $parts) {
|
||||
if ($part -match "(\w+)=(\d+)") {
|
||||
$userInfo[$matches[1]] = [int64]$matches[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return @{
|
||||
success = $false
|
||||
error = "Ошибка загрузки: $($_.Exception.Message)"
|
||||
rawContent = $null
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Пробуем парсить как JSON
|
||||
try {
|
||||
$config = $rawContent | ConvertFrom-Json
|
||||
return @{
|
||||
success = $true
|
||||
config = $config
|
||||
rawContent = $rawContent
|
||||
userInfo = $userInfo
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# JSON не распарсился — возвращаем rawContent для дальнейшей обработки
|
||||
return @{
|
||||
success = $false
|
||||
error = "Ответ не в формате JSON (возможно Base64 или список ссылок)"
|
||||
rawContent = $rawContent
|
||||
userInfo = $userInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# ==========================================
|
||||
# 🖥️ SYSTEM UTILS
|
||||
# ==========================================
|
||||
|
||||
# --- СИСТЕМНАЯ ИНФОРМАЦИЯ ---
|
||||
|
||||
function Get-SystemInfo {
|
||||
return @{
|
||||
os = "windows"
|
||||
version = [System.Environment]::OSVersion.Version.Major.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
# --- DOCKER ---
|
||||
|
||||
function Test-Docker {
|
||||
$status = @{
|
||||
Installed = $false
|
||||
Running = $false
|
||||
Compose = $false
|
||||
}
|
||||
|
||||
try {
|
||||
$ver = docker --version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $status.Installed = $true }
|
||||
}
|
||||
catch {}
|
||||
|
||||
if ($status.Installed) {
|
||||
try {
|
||||
$info = docker info 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $status.Running = $true }
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
if ($status.Running) {
|
||||
try {
|
||||
$comp = docker compose version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $status.Compose = $true }
|
||||
}
|
||||
catch {
|
||||
# Check legacy
|
||||
try {
|
||||
$comp = docker-compose --version 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $status.Compose = $true }
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return $status
|
||||
}
|
||||
|
||||
# --- СЛУЖБЫ И ЗАДАЧИ ---
|
||||
|
||||
function Manage-ScheduledTask {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$ExePath,
|
||||
[string]$Arguments,
|
||||
[string]$WorkDir,
|
||||
[string]$Action = "Install" # Install, Uninstall, Start, Stop
|
||||
)
|
||||
|
||||
switch ($Action) {
|
||||
"Install" {
|
||||
# Удаляем старую
|
||||
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
|
||||
|
||||
$act = New-ScheduledTaskAction -Execute "$ExePath" -Argument "$Arguments" -WorkingDirectory $WorkDir
|
||||
$trig = New-ScheduledTaskTrigger -AtStartup
|
||||
$princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
$sett = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
||||
|
||||
Register-ScheduledTask -TaskName $Name -Action $act -Trigger $trig -Principal $princ -Settings $sett -Force | Out-Null
|
||||
return $true
|
||||
}
|
||||
"Uninstall" {
|
||||
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
"Start" {
|
||||
Start-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||
}
|
||||
"Stop" {
|
||||
Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||
# Пытаемся убить процесс по имени exe
|
||||
if ($ExePath) {
|
||||
$procName = [System.IO.Path]::GetFileNameWithoutExtension($ExePath)
|
||||
if ($procName) {
|
||||
Stop-Process -Name $procName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TaskStatus {
|
||||
param([string]$Name)
|
||||
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
# Если задача в статусе Running — возвращаем Running
|
||||
if ($task.State -eq "Running") {
|
||||
return "Running"
|
||||
}
|
||||
|
||||
# Если задача Ready — проверяем, работает ли процесс sing-box
|
||||
# (scheduled task может быть Ready даже когда процесс работает)
|
||||
$process = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue
|
||||
if ($process) {
|
||||
return "Running"
|
||||
}
|
||||
|
||||
return $task.State
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
|
||||
|
||||
function Ensure-FirewallPort {
|
||||
param(
|
||||
[int]$Port,
|
||||
[string]$Name,
|
||||
[string]$Protocol = "TCP"
|
||||
)
|
||||
|
||||
$rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue
|
||||
if (-not $rule) {
|
||||
New-NetFirewallRule -DisplayName $Name -Direction Inbound -LocalPort $Port -Protocol $Protocol -Action Allow -Profile Any | Out-Null
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-LocalIPs {
|
||||
return (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias * | Where-Object { $_.IPAddress -notmatch "^127\." -and $_.IPAddress -notmatch "^169\.254\." }).IPAddress
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
# ==========================================
|
||||
# 🎮 DISCORD PROXY SETUP
|
||||
# ==========================================
|
||||
|
||||
param(
|
||||
[switch]$Force,
|
||||
[switch]$Debug
|
||||
)
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$ScriptDir\lib\Common.ps1"
|
||||
. "$ScriptDir\lib\Net.ps1"
|
||||
. "$ScriptDir\lib\System.ps1"
|
||||
|
||||
if ($Debug) { Set-DebugMode -Enabled $true }
|
||||
|
||||
Write-Header "НАСТРОЙКА DISCORD / VESKTOP" -ClearScreen
|
||||
|
||||
Ensure-Admin
|
||||
|
||||
$InstallPath = "C:\Tools\ProxiFyre"
|
||||
$ConfigPath = "$InstallPath\app-config.json"
|
||||
$DriverUrl = "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi"
|
||||
$AppUrl = "https://github.com/wiresock/proxifyre/releases/download/v2.1.4/ProxiFyre-v2.1.4-x64-signed.zip"
|
||||
|
||||
# --- ФУНКЦИИ ---
|
||||
|
||||
function Test-ProxyConnection {
|
||||
param([string]$ProxyAddr)
|
||||
|
||||
Write-Info "Проверка подключения к прокси $ProxyAddr..."
|
||||
|
||||
try {
|
||||
$parts = $ProxyAddr -split ":"
|
||||
$host_ = $parts[0]
|
||||
$port = [int]$parts[1]
|
||||
|
||||
# 1. Проверяем TCP соединение
|
||||
$tcp = New-Object System.Net.Sockets.TcpClient
|
||||
$tcp.Connect($host_, $port)
|
||||
$tcp.Close()
|
||||
Write-Success "TCP соединение установлено"
|
||||
|
||||
# 2. Пробуем получить внешний IP через прокси (используем curl для SOCKS5)
|
||||
try {
|
||||
$result = & curl.exe -s -x "socks5://$ProxyAddr" "http://v4.ident.me" --connect-timeout 5 2>$null
|
||||
if ($result -match "^\d+\.\d+\.\d+\.\d+$") {
|
||||
Write-Success "Внешний IP через прокси: $result"
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
Write-Warning "TCP работает, но не удалось получить IP. Возможно прокси не полностью настроен."
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Error "Не удалось подключиться к $ProxyAddr"
|
||||
Write-Host " Убедитесь, что прокси запущен и доступен." -ForegroundColor Gray
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentConfig {
|
||||
if (Test-Path $ConfigPath) {
|
||||
try {
|
||||
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
return @{
|
||||
Apps = $cfg.proxies[0].appNames -join ", "
|
||||
Proxy = $cfg.proxies[0].socks5ProxyEndpoint
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Install-ProxiFyre {
|
||||
# Установка драйвера
|
||||
Write-Step "Установка драйвера..."
|
||||
$msi = "$env:TEMP\WinpkFilter.msi"
|
||||
if (Download-File -Url $DriverUrl -Destination $msi) {
|
||||
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart" -Wait
|
||||
Write-Success "Драйвер готов"
|
||||
}
|
||||
|
||||
# Установка ProxiFyre
|
||||
Write-Step "Установка ProxiFyre..."
|
||||
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
|
||||
$zip = "$env:TEMP\ProxiFyre.zip"
|
||||
if (Download-File -Url $AppUrl -Destination $zip) {
|
||||
Expand-Archive -Path $zip -DestinationPath $InstallPath -Force
|
||||
$exe = Get-ChildItem $InstallPath -Recurse -Filter "ProxiFyre.exe" | Select -First 1
|
||||
if ($exe.DirectoryName -ne $InstallPath) {
|
||||
Copy-Item "$($exe.DirectoryName)\*" $InstallPath -Recurse -Force
|
||||
}
|
||||
Write-Success "Распаковано"
|
||||
}
|
||||
}
|
||||
|
||||
function Configure-And-Start {
|
||||
param($TargetApps, $ProxyAddr)
|
||||
|
||||
# Конфиг
|
||||
$cfg = @{
|
||||
logLevel = "Info"
|
||||
proxies = @(@{
|
||||
appNames = $TargetApps
|
||||
socks5ProxyEndpoint = $ProxyAddr
|
||||
supportedProtocols = @("TCP", "UDP")
|
||||
})
|
||||
excludes = @()
|
||||
}
|
||||
$cfg | ConvertTo-Json -Depth 5 | Set-Content $ConfigPath -Encoding UTF8
|
||||
|
||||
# Служба
|
||||
Write-Step "Перезапуск службы..."
|
||||
if (Get-DebugMode) {
|
||||
& "$InstallPath\ProxiFyre.exe" stop
|
||||
& "$InstallPath\ProxiFyre.exe" install
|
||||
& "$InstallPath\ProxiFyre.exe" start
|
||||
}
|
||||
else {
|
||||
& "$InstallPath\ProxiFyre.exe" stop 2>&1 | Out-Null
|
||||
& "$InstallPath\ProxiFyre.exe" install 2>&1 | Out-Null
|
||||
& "$InstallPath\ProxiFyre.exe" start 2>&1 | Out-Null
|
||||
}
|
||||
|
||||
Write-Success "Готово! Discord должен работать через прокси."
|
||||
}
|
||||
|
||||
function Select-Apps {
|
||||
Write-Host "`n🎮 Какие приложения проксировать?" -ForegroundColor Yellow
|
||||
$appOpts = [Ordered]@{
|
||||
"1" = "Discord"
|
||||
"2" = "Vesktop"
|
||||
"3" = "Discord + Vesktop"
|
||||
}
|
||||
$appChoice = Show-Menu -Options $appOpts
|
||||
$result = switch ($appChoice) {
|
||||
"1" { @("Discord") }
|
||||
"2" { @("Vesktop") }
|
||||
"3" { @("Vesktop", "Discord") }
|
||||
default { @("Discord") }
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-ProxyAddress {
|
||||
# Проверяем локальный sing-box
|
||||
$singboxStatus = Get-TaskStatus -Name "SingBoxProxy"
|
||||
$localProxy = "127.0.0.1:1080"
|
||||
|
||||
if ($singboxStatus -eq "Running") {
|
||||
Write-Info "Обнаружен работающий VPN клиент (Sing-box)."
|
||||
Write-Host " Рекомендуется использовать локальный прокси: " -NoNewline -ForegroundColor Gray
|
||||
Write-Host $localProxy -ForegroundColor Green
|
||||
|
||||
$useLocal = Read-Host " Использовать локальный? (y/n) [y]"
|
||||
if ($useLocal -ne 'n') {
|
||||
return $localProxy
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "VPN клиент не запущен!"
|
||||
Write-Host " Вы можете указать адрес удалённого прокси." -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Запрашиваем адрес
|
||||
while ($true) {
|
||||
$proxyAddr = Read-Host "`n Введите адрес прокси (IP:порт)"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($proxyAddr)) {
|
||||
Write-Warning "Адрес не указан"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($proxyAddr -notmatch "^[\d\.]+:\d+$") {
|
||||
Write-Error "Неверный формат. Ожидается: IP:порт (например 192.168.1.100:1080)"
|
||||
continue
|
||||
}
|
||||
|
||||
# Проверяем подключение
|
||||
if (Test-ProxyConnection -ProxyAddr $proxyAddr) {
|
||||
return $proxyAddr
|
||||
}
|
||||
|
||||
$retry = Read-Host " Попробовать другой адрес? (y/n)"
|
||||
if ($retry -ne 'y') { return $null }
|
||||
}
|
||||
}
|
||||
|
||||
# --- MAIN ---
|
||||
|
||||
$isInstalled = Test-Path "$InstallPath\ProxiFyre.exe"
|
||||
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
|
||||
$currentConfig = Get-CurrentConfig
|
||||
|
||||
if ($isInstalled -and $currentConfig -and -not $Force) {
|
||||
# Уже установлено — показываем меню управления
|
||||
Write-Info "ProxiFyre уже установлен."
|
||||
Write-Host ""
|
||||
Write-Host " Статус: " -NoNewline -ForegroundColor Gray
|
||||
if ($discSvc.Status -eq 'Running') {
|
||||
Write-Host "АКТИВЕН" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "ОСТАНОВЛЕН" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host " Приложения: $($currentConfig.Apps)" -ForegroundColor Gray
|
||||
Write-Host " Прокси: $($currentConfig.Proxy)" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
$opts = [Ordered]@{
|
||||
"1" = "Изменить настройки (приложения/прокси)"
|
||||
"2" = "Проверить подключение к прокси"
|
||||
"3" = "Перезапустить службу"
|
||||
"4" = "Остановить службу"
|
||||
"5" = "Переустановить"
|
||||
"b" = "Назад"
|
||||
}
|
||||
|
||||
$action = Show-Menu -Options $opts
|
||||
|
||||
switch ($action) {
|
||||
"1" {
|
||||
$targetApps = Select-Apps
|
||||
$proxyAddr = Get-ProxyAddress
|
||||
if ($proxyAddr) {
|
||||
Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr
|
||||
}
|
||||
}
|
||||
"2" {
|
||||
Test-ProxyConnection -ProxyAddr $currentConfig.Proxy | Out-Null
|
||||
}
|
||||
"3" {
|
||||
Write-Step "Перезапуск службы..."
|
||||
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow
|
||||
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "start" -Wait -NoNewWindow
|
||||
Write-Success "Перезапущено!"
|
||||
}
|
||||
"4" {
|
||||
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow
|
||||
Write-Success "Остановлено!"
|
||||
}
|
||||
"5" {
|
||||
$Force = $true
|
||||
}
|
||||
"b" { exit }
|
||||
}
|
||||
|
||||
if (-not $Force) {
|
||||
Start-Sleep -Seconds 2
|
||||
exit
|
||||
}
|
||||
}
|
||||
|
||||
# --- НОВАЯ УСТАНОВКА ---
|
||||
|
||||
if (-not $isInstalled -or $Force) {
|
||||
Install-ProxiFyre
|
||||
}
|
||||
|
||||
$targetApps = Select-Apps
|
||||
$proxyAddr = Get-ProxyAddress
|
||||
|
||||
if (-not $proxyAddr) {
|
||||
Write-Error "Прокси не настроен. Выход."
|
||||
Start-Sleep -Seconds 2
|
||||
exit
|
||||
}
|
||||
|
||||
Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr
|
||||
Start-Sleep -Seconds 3
|
||||
@@ -1,417 +0,0 @@
|
||||
# ==========================================
|
||||
# 📦 SING-BOX NATIVE INSTALLER
|
||||
# ==========================================
|
||||
|
||||
param(
|
||||
[switch]$Force,
|
||||
[switch]$Debug,
|
||||
[string]$SubscriptionUrl = ""
|
||||
)
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$ScriptDir\lib\Common.ps1"
|
||||
. "$ScriptDir\lib\Net.ps1"
|
||||
. "$ScriptDir\lib\System.ps1"
|
||||
|
||||
# --- CONFIG ---
|
||||
$SingboxVersion = "1.11.4"
|
||||
$InstallDir = "C:\Tools\sing-box"
|
||||
$LocalProxyPort = 1080
|
||||
$SingboxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingboxVersion/sing-box-$SingboxVersion-windows-amd64.zip"
|
||||
$TaskName = "SingBoxProxy"
|
||||
|
||||
Ensure-Admin
|
||||
|
||||
# --- LOGIC ---
|
||||
|
||||
function Select-Server {
|
||||
param($Config)
|
||||
|
||||
$outbounds = $Config.outbounds
|
||||
$servers = @()
|
||||
|
||||
foreach ($outbound in $outbounds) {
|
||||
if ($outbound.type -in @("vless", "vmess", "trojan", "shadowsocks", "hysteria2")) {
|
||||
$servers += @{
|
||||
tag = $outbound.tag
|
||||
type = $outbound.type
|
||||
server = $outbound.server
|
||||
server_port = $outbound.server_port
|
||||
outbound = $outbound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($servers.Count -eq 0) {
|
||||
Write-Error "Серверы не найдены в подписке!"
|
||||
return $null
|
||||
}
|
||||
|
||||
$options = [Ordered]@{}
|
||||
for ($i = 0; $i -lt $servers.Count; $i++) {
|
||||
$s = $servers[$i]
|
||||
$options["$($i+1)"] = "$($s.tag) ($($s.server):$($s.server_port))"
|
||||
}
|
||||
|
||||
$choice = Show-Menu -Title "🌐 Доступные серверы" -Options $options -Prompt "👉 Выберите сервер (номер)"
|
||||
$index = [int]$choice - 1
|
||||
|
||||
if ($index -lt 0 -or $index -ge $servers.Count) {
|
||||
Write-Error "Неверный выбор!"
|
||||
return $null
|
||||
}
|
||||
|
||||
return $servers[$index]
|
||||
}
|
||||
|
||||
function New-SingboxConfig {
|
||||
param($Outbound, $Port)
|
||||
|
||||
return @{
|
||||
log = @{ level = "info"; timestamp = $true }
|
||||
dns = @{ independent_cache = $true }
|
||||
inbounds = @(
|
||||
@{
|
||||
type = "socks"
|
||||
tag = "socks-in"
|
||||
listen = "0.0.0.0"
|
||||
listen_port = $Port
|
||||
}
|
||||
)
|
||||
outbounds = @(
|
||||
$Outbound,
|
||||
@{ type = "direct"; tag = "direct" }
|
||||
)
|
||||
route = @{
|
||||
final = $Outbound.tag
|
||||
auto_detect_interface = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Parse-VlessUrl {
|
||||
param([string]$Url)
|
||||
|
||||
if (-not $Url.StartsWith("vless://")) { throw "URL должен начинаться с vless://" }
|
||||
|
||||
# Remove scheme
|
||||
$raw = $Url.Substring(8)
|
||||
|
||||
# Split fragment
|
||||
$tag = "reality"
|
||||
if ($raw -match "#(.*)$") {
|
||||
$tag = [System.Web.HttpUtility]::UrlDecode($matches[1])
|
||||
$raw = $raw -replace "#.*$", ""
|
||||
}
|
||||
|
||||
# Split query
|
||||
$queryStr = ""
|
||||
if ($raw -match "\?(.*)$") {
|
||||
$queryStr = $matches[1]
|
||||
$raw = $raw -replace "\?.*$", ""
|
||||
}
|
||||
|
||||
# Parse UUID@HOST:PORT
|
||||
if ($raw -notmatch "([^@]+)@([^:]+):(\d+)") { throw "Неверный формат vless (ожидается uuid@host:port)" }
|
||||
$uuid = $matches[1][0]
|
||||
$serverHost = $matches[2][0]
|
||||
$port = [int]$matches[3][0] # Fix for regex object access in PS
|
||||
|
||||
if (-not $uuid) {
|
||||
# Fallback if regex returns match info differently in different PS versions
|
||||
$uuid = $matches[1]
|
||||
$serverHost = $matches[2]
|
||||
$port = [int]$matches[3]
|
||||
}
|
||||
|
||||
|
||||
# Parse Query
|
||||
$params = @{}
|
||||
if ($queryStr) {
|
||||
$parts = $queryStr -split "&"
|
||||
foreach ($p in $parts) {
|
||||
$kv = $p -split "="
|
||||
if ($kv.Count -eq 2) {
|
||||
$params[[System.Web.HttpUtility]::UrlDecode($kv[0])] = [System.Web.HttpUtility]::UrlDecode($kv[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Extract
|
||||
$pbk = if ($params["pbk"]) { $params["pbk"] } else { throw "Отсутствует параметр pbk (Public Key)" }
|
||||
$sid = if ($params["sid"]) { $params["sid"] } else { throw "Отсутствует параметр sid (Short ID)" }
|
||||
$sni = if ($params["sni"]) { $params["sni"] } else { $serverHost }
|
||||
$fp = if ($params["fp"]) { $params["fp"] } else { "chrome" }
|
||||
$flow = if ($params["flow"]) { $params["flow"] } else { "" }
|
||||
|
||||
return @{
|
||||
uuid = $uuid
|
||||
server = $serverHost
|
||||
server_port = $port
|
||||
tag = $tag
|
||||
public_key = $pbk
|
||||
short_id = $sid
|
||||
server_name = $sni
|
||||
fingerprint = $fp
|
||||
flow = $flow
|
||||
}
|
||||
}
|
||||
|
||||
# --- MAIN ---
|
||||
|
||||
if ($Debug) { Set-DebugMode -Enabled $true }
|
||||
|
||||
Write-Header "NATIVE SING-BOX (UDP ПОДДЕРЖКА)" -ClearScreen
|
||||
|
||||
$taskStatus = Get-TaskStatus -Name $TaskName
|
||||
|
||||
if ($taskStatus -and -not $Force) {
|
||||
Write-Info "Sing-box уже установлен."
|
||||
Write-Host " Статус: $taskStatus" -ForegroundColor ($taskStatus -eq "Running" ? "Green" : "Red")
|
||||
Write-Host ""
|
||||
|
||||
$opts = [Ordered]@{
|
||||
"1" = "Сменить сервер (из подписки)"
|
||||
"2" = "Ввести новую ссылку на подписку"
|
||||
"3" = "Перезапустить службу"
|
||||
"4" = "Остановить службу"
|
||||
"5" = "Показать конфиг"
|
||||
"6" = "Переустановить"
|
||||
"b" = "Назад"
|
||||
}
|
||||
|
||||
$act = Show-Menu -Options $opts
|
||||
|
||||
switch ($act) {
|
||||
"1" {
|
||||
# Reload existing sub logic could be added here, currently just re-runs install flow partially
|
||||
# Simplification: treat as new setup but try to load saved sub url
|
||||
$Force = $true
|
||||
}
|
||||
"2" { $SubscriptionUrl = ""; $Force = $true }
|
||||
"3" { Manage-ScheduledTask -Name $TaskName -Action "Start"; Write-Success "Запущено!"; exit }
|
||||
"4" { Manage-ScheduledTask -Name $TaskName -Action "Stop"; Write-Success "Остановлено!"; exit }
|
||||
"5" { Get-Content "$InstallDir\config.json"; exit }
|
||||
"6" { $Force = $true }
|
||||
"b" { exit }
|
||||
}
|
||||
}
|
||||
|
||||
if ($Force -or -not $taskStatus) {
|
||||
# 1. Загрузка
|
||||
Write-Step "Установка Sing-box..."
|
||||
if (!(Test-Path "$InstallDir\sing-box.exe")) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$zipCtx = "$env:TEMP\sing-box.zip"
|
||||
if (Download-File -Url $SingboxUrl -Destination $zipCtx) {
|
||||
Expand-Archive -Path $zipCtx -DestinationPath $env:TEMP -Force
|
||||
$extracted = Get-ChildItem "$env:TEMP\sing-box-*" -Directory | Select -First 1
|
||||
Copy-Item "$($extracted.FullName)\sing-box.exe" "$InstallDir\sing-box.exe" -Force
|
||||
Remove-Item $zipCtx; Remove-Item $extracted.FullName -Recurse -Force
|
||||
Write-Success "Sing-box скачан"
|
||||
}
|
||||
else {
|
||||
Read-Host "Нажмите Enter для выхода..."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Подписка
|
||||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
||||
# Try load saved
|
||||
$savedSub = "$InstallDir\sub_info.json"
|
||||
if (Test-Path $savedSub) {
|
||||
try {
|
||||
$json = Get-Content $savedSub -Raw | ConvertFrom-Json
|
||||
if ($json.url) {
|
||||
Write-Info "Найдена сохраненная подписка: $($json.url)"
|
||||
if ((Read-Host "Использовать? (y/n)") -eq 'y') { $SubscriptionUrl = $json.url }
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
||||
$SubscriptionUrl = Read-Host "`n🔗 Введите URL подписки (VLESS)"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
||||
Write-Error "Url не указан"
|
||||
Read-Host "Нажмите Enter для выхода..."
|
||||
exit
|
||||
}
|
||||
|
||||
# --- PARSING ---
|
||||
$data = @{ success = $false; config = $null; error = "" }
|
||||
|
||||
if ($SubscriptionUrl.StartsWith("vless://")) {
|
||||
try {
|
||||
$p = Parse-VlessUrl -Url $SubscriptionUrl
|
||||
$outbound = [Ordered]@{
|
||||
type = "vless"
|
||||
tag = $p.tag
|
||||
server = $p.server
|
||||
server_port = $p.server_port
|
||||
uuid = $p.uuid
|
||||
flow = $p.flow
|
||||
tls = @{
|
||||
enabled = $true
|
||||
server_name = $p.server_name
|
||||
utls = @{ enabled = $true; fingerprint = $p.fingerprint }
|
||||
reality = @{
|
||||
enabled = $true
|
||||
public_key = $p.public_key
|
||||
short_id = $p.short_id
|
||||
}
|
||||
}
|
||||
packet_encoding = "xudp"
|
||||
}
|
||||
$data.success = $true
|
||||
$data.config = @{ outbounds = @($outbound) }
|
||||
}
|
||||
catch {
|
||||
$data.error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
else {
|
||||
$data = Get-SubscriptionData -Url $SubscriptionUrl -Headers (Get-SubscriptionHeaders)
|
||||
}
|
||||
|
||||
|
||||
# --- PARSING LOGIC ENHANCEMENT ---
|
||||
if (-not $data.success) {
|
||||
# Fallback: Try to handle non-JSON body (Base64 or Plain Text)
|
||||
try {
|
||||
Write-Info "JSON парсинг не удался, пробую как список ссылок..."
|
||||
$content = $data.rawContent
|
||||
|
||||
# Base64 decode if needed
|
||||
if ($content -match "^[A-Za-z0-9+/=]+$") {
|
||||
try {
|
||||
$bytes = [System.Convert]::FromBase64String($content)
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
# Try to find vless:// links
|
||||
$links = $content -split "[\r\n]+" | Where-Object { $_ -match "^vless://" }
|
||||
|
||||
if ($links.Count -gt 0) {
|
||||
Write-Success "Найдено ссылок: $($links.Count)"
|
||||
|
||||
# Mock a config object with these links as "outbounds"
|
||||
# Note: We can't fully parsing VLESS query params in pure PS easily without a lot of regex
|
||||
# So we will try a simpler approach: Let sing-box do it? No, sing-box needs config.
|
||||
|
||||
# WORKAROUND: Create a minimal outbound for each link
|
||||
# Parsing `vless://UUID@HOST:PORT?security=reality&...#NAME`
|
||||
$parsedOutbounds = @()
|
||||
|
||||
foreach ($link in $links) {
|
||||
if ($link -match "vless://([^@]+)@([^:]+):(\d+)(\?.*)?(#.*)?") {
|
||||
$uuid = $matches[1]
|
||||
$server = $matches[2]
|
||||
$port = [int]$matches[3]
|
||||
$query = $matches[4]
|
||||
$hash = $matches[5]
|
||||
|
||||
$tag = if ($hash) { $hash.Substring(1) } else { "${server}:${port}" }
|
||||
$tag = [System.Web.HttpUtility]::UrlDecode($tag)
|
||||
|
||||
# Parse Query Params
|
||||
$flow = ""; $fp = ""; $pbk = ""; $sid = ""; $sni = ""; $serviceName = ""
|
||||
|
||||
if ($query) {
|
||||
if ($query -match "flow=([^&]+)") { $flow = $matches[1] }
|
||||
if ($query -match "fp=([^&]+)") { $fp = $matches[1] }
|
||||
if ($query -match "pbk=([^&]+)") { $pbk = $matches[1] }
|
||||
if ($query -match "sid=([^&]+)") { $sid = $matches[1] }
|
||||
if ($query -match "sni=([^&]+)") { $sni = $matches[1] }
|
||||
if ($query -match "serviceName=([^&]+)") { $serviceName = $matches[1] }
|
||||
}
|
||||
|
||||
# Construct Sing-box outbound (REALITY based assumption for modern vless)
|
||||
$out = [Ordered]@{
|
||||
type = "vless"
|
||||
tag = $tag
|
||||
server = $server
|
||||
server_port = $port
|
||||
uuid = $uuid
|
||||
flow = $flow
|
||||
tls = @{
|
||||
enabled = $true
|
||||
server_name = $sni
|
||||
utls = @{ enabled = $true; fingerprint = $fp }
|
||||
reality = @{
|
||||
enabled = $true
|
||||
public_key = $pbk
|
||||
short_id = $sid
|
||||
}
|
||||
}
|
||||
packet_encoding = "xudp"
|
||||
}
|
||||
$parsedOutbounds += $out
|
||||
}
|
||||
}
|
||||
|
||||
if ($parsedOutbounds.Count -gt 0) {
|
||||
$data.success = $true
|
||||
$data.config = @{ outbounds = $parsedOutbounds }
|
||||
$data.error = $null
|
||||
}
|
||||
else {
|
||||
throw "Не удалось распарсить VLESS ссылки"
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
throw $data.error
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Ошибка обработки подписки: $_"
|
||||
Write-Host " Скрипт поддерживает: SIP008 (JSON) или список VLESS+Reality ссылок." -ForegroundColor Yellow
|
||||
Read-Host "Нажмите Enter для выхода..."
|
||||
exit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Save sub info
|
||||
@{ url = $SubscriptionUrl } | ConvertTo-Json | Set-Content "$InstallDir\sub_info.json"
|
||||
|
||||
# 3. Выбор сервера
|
||||
$server = Select-Server -Config $data.config
|
||||
if (!$server) {
|
||||
Read-Host "Нажмите Enter для выхода..."
|
||||
exit
|
||||
}
|
||||
|
||||
# 4. Конфиг
|
||||
$cfg = New-SingboxConfig -Outbound $server.outbound -Port $LocalProxyPort
|
||||
$cfg | ConvertTo-Json -Depth 10 | Set-Content "$InstallDir\config.json" -Encoding UTF8
|
||||
|
||||
# 5. Задача
|
||||
Manage-ScheduledTask -Name $TaskName -ExePath "$InstallDir\sing-box.exe" -Arguments "run -c `"$InstallDir\config.json`"" -WorkDir $InstallDir -Action "Install"
|
||||
Manage-ScheduledTask -Name $TaskName -Action "Start"
|
||||
|
||||
# 6. Firewall
|
||||
if (Ensure-FirewallPort -Port $LocalProxyPort -Name "SingBox-Proxy-Port") {
|
||||
Write-Success "Правило Firewall создано (порт $LocalProxyPort)"
|
||||
}
|
||||
|
||||
Write-Success "Успешно установлено и запущено!"
|
||||
Write-Info "Локальный прокси: 127.0.0.1:$LocalProxyPort"
|
||||
|
||||
$ips = Get-LocalIPs
|
||||
if ($ips) {
|
||||
Write-Info "Доступно из сети по адресам:"
|
||||
foreach ($ip in $ips) {
|
||||
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# ==========================================
|
||||
# 🗑️ UNINSTALL ALL (CLEANUP)
|
||||
# ==========================================
|
||||
|
||||
param([switch]$Debug)
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$ScriptDir\lib\Common.ps1"
|
||||
. "$ScriptDir\lib\System.ps1"
|
||||
|
||||
if ($Debug) { Set-DebugMode -Enabled $true }
|
||||
|
||||
Write-Header "ПОЛНОЕ УДАЛЕНИЕ" -ClearScreen
|
||||
|
||||
Ensure-Admin
|
||||
|
||||
Write-Warning "Это действие удалит весь установленный софт:"
|
||||
Write-Host " - Sing-box (Служба и файлы)" -ForegroundColor Gray
|
||||
Write-Host " - ProxiFyre (Служба и файлы)" -ForegroundColor Gray
|
||||
Write-Host " - Драйвер WinPacketFilter" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
if ((Read-Host "Вы уверены? (y/n)") -ne 'y') { exit }
|
||||
|
||||
Write-Step "Удаление Sing-box..."
|
||||
Manage-ScheduledTask -Name "SingBoxProxy" -Action "Stop"
|
||||
Manage-ScheduledTask -Name "SingBoxProxy" -Action "Uninstall"
|
||||
|
||||
if (Test-Path "C:\Tools\sing-box") {
|
||||
Remove-Item "C:\Tools\sing-box" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Success "Файлы удалены"
|
||||
}
|
||||
|
||||
Write-Step "Удаление Discord Proxy (ProxiFyre)..."
|
||||
$pfDir = "C:\Tools\ProxiFyre"
|
||||
if (Test-Path "$pfDir\ProxiFyre.exe") {
|
||||
if (Get-DebugMode) {
|
||||
& "$pfDir\ProxiFyre.exe" uninstall
|
||||
}
|
||||
else {
|
||||
& "$pfDir\ProxiFyre.exe" uninstall 2>&1 | Out-Null
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Success "Служба удалена"
|
||||
}
|
||||
|
||||
if (Test-Path $pfDir) {
|
||||
Remove-Item $pfDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Success "Файлы удалены"
|
||||
}
|
||||
|
||||
Write-Step "Удаление драйвера..."
|
||||
# Тут сложно удалить MSI тихо без GUID, но попробуем через known path или пропустим, т.к. драйвер может быть нужен другим
|
||||
Write-Info "Драйвер WinPacketFilter оставлен (он может использоваться другим ПО)."
|
||||
Write-Info "Если нужно, удалите его через 'Установка и удаление программ'."
|
||||
|
||||
Write-Success "Очистка завершена!"
|
||||
Start-Sleep -Seconds 3
|
||||
101
src/server/clientSettings.js
Normal file
101
src/server/clientSettings.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
|
||||
const DEFAULT_CLIENT_SETTINGS = {
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
sharedProxyControlUrl: "",
|
||||
sharedProxy: null,
|
||||
};
|
||||
|
||||
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;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!["http:", "https:"].includes(url.protocol)) return "";
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSharedProxy(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const host = String(value.host || "").trim();
|
||||
const port = Number.parseInt(value.port, 10);
|
||||
const protocol = value.protocol === "http" ? "http" : "socks5";
|
||||
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
checkedAt: value.checkedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeClientSettings(input = {}) {
|
||||
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
|
||||
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
|
||||
return {
|
||||
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||
proxyPort: normalizeProxyPort(input.proxyPort),
|
||||
sharedProxyEnabled,
|
||||
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
|
||||
sharedProxy,
|
||||
};
|
||||
}
|
||||
|
||||
export function readClientSettings() {
|
||||
return normalizeClientSettings({
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
proxyPort: settings.proxyPort,
|
||||
...readJson(settings.clientSettingsPath, {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function writeClientSettings(input) {
|
||||
const normalized = normalizeClientSettings({
|
||||
...readClientSettings(),
|
||||
...(input && typeof input === "object" ? input : {}),
|
||||
});
|
||||
writeJson(settings.clientSettingsPath, normalized);
|
||||
return normalized;
|
||||
}
|
||||
31
src/server/config.js
Normal file
31
src/server/config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
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),
|
||||
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,
|
||||
distDir: process.env.DIST_DIR || "/app/dist",
|
||||
configPath:
|
||||
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||
statePath: path.join(dataDir, "state.json"),
|
||||
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
||||
clientSettingsPath: path.join(dataDir, "client-settings.json"),
|
||||
devicesPath: path.join(dataDir, "devices.json"),
|
||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName: "VPN Proxy Gateway",
|
||||
};
|
||||
153
src/server/devices.js
Normal file
153
src/server/devices.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
|
||||
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
|
||||
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
||||
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||
export const DEFAULT_PROXY_MODE = "vpn";
|
||||
export const TPROXY_INBOUND = "tproxy-in";
|
||||
export const MIXED_INBOUND = "mixed-in";
|
||||
|
||||
const IPISH_RE = /^[\.\d:/]+$/;
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
if (value === "bypass") return "direct";
|
||||
return DEVICE_MODES.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeDefaultMode(mode) {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_DEVICE_MODE;
|
||||
}
|
||||
|
||||
function normalizeProxyMode(mode) {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_PROXY_MODE;
|
||||
}
|
||||
|
||||
function normalizeIp(ip) {
|
||||
const value = String(ip || "").trim();
|
||||
return value && IPISH_RE.test(value) ? value : "";
|
||||
}
|
||||
|
||||
function normalizeMac(mac) {
|
||||
return String(mac || "").trim();
|
||||
}
|
||||
|
||||
function fromLegacyDeviceRules(input) {
|
||||
const rules = Array.isArray(input) ? input : [];
|
||||
const devices = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const sourceIps = Array.isArray(rule?.sourceIps) ? rule.sourceIps : [];
|
||||
const mode = normalizeDeviceMode(rule?.outbound, "direct");
|
||||
sourceIps.forEach((sourceIp, ipIndex) => {
|
||||
const ip = normalizeIp(sourceIp);
|
||||
if (!ip) return;
|
||||
devices.push({
|
||||
id: String(rule.id || `dev-${devices.length}`) + `-${ipIndex}`,
|
||||
name: String(rule.name || `Устройство ${devices.length + 1}`).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
ip,
|
||||
mac: "",
|
||||
mode,
|
||||
lastSeen: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
defaultTransparentMode: DEFAULT_DEVICE_MODE,
|
||||
proxyDefaultMode: DEFAULT_PROXY_MODE,
|
||||
devices,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDeviceProfiles(input) {
|
||||
const raw =
|
||||
input && typeof input === "object" && !Array.isArray(input)
|
||||
? input
|
||||
: { devices: input };
|
||||
const rawDevices = Array.isArray(raw.devices) ? raw.devices : [];
|
||||
|
||||
return {
|
||||
defaultTransparentMode: normalizeDefaultMode(
|
||||
raw.defaultTransparentMode || raw.defaultMode,
|
||||
),
|
||||
proxyDefaultMode: normalizeProxyMode(raw.proxyDefaultMode),
|
||||
devices: rawDevices.map((device, index) => ({
|
||||
id: String(device.id || `dev-${Date.now()}-${index}`),
|
||||
name: String(device.name || `Устройство ${index + 1}`).trim(),
|
||||
enabled: device.enabled !== false,
|
||||
ip: normalizeIp(device.ip || device.sourceIp),
|
||||
mac: normalizeMac(device.mac),
|
||||
mode: normalizeDeviceMode(device.mode || device.outbound, "rules"),
|
||||
lastSeen: device.lastSeen || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function readDeviceProfiles() {
|
||||
if (fs.existsSync(settings.devicesPath)) {
|
||||
return normalizeDeviceProfiles(readJson(settings.devicesPath, null));
|
||||
}
|
||||
|
||||
if (fs.existsSync(settings.deviceRulesPath)) {
|
||||
return normalizeDeviceProfiles(
|
||||
fromLegacyDeviceRules(readJson(settings.deviceRulesPath, [])),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
defaultTransparentMode: DEFAULT_DEVICE_MODE,
|
||||
proxyDefaultMode: DEFAULT_PROXY_MODE,
|
||||
devices: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function writeDeviceProfiles(value) {
|
||||
const normalized = normalizeDeviceProfiles(value);
|
||||
writeJson(settings.devicesPath, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeCidr(ip) {
|
||||
const value = normalizeIp(ip);
|
||||
if (!value) return "";
|
||||
return value.includes("/") ? value : `${value}/32`;
|
||||
}
|
||||
|
||||
export function deviceCidrs(devices, modes) {
|
||||
const allowedModes = new Set(Array.isArray(modes) ? modes : [modes]);
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
.filter((device) => device.enabled !== false && allowedModes.has(device.mode))
|
||||
.map((device) => normalizeCidr(device.ip))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function legacyDeviceRulesFromProfiles(profiles) {
|
||||
const { devices } = normalizeDeviceProfiles(profiles);
|
||||
return devices.map((device) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
enabled: device.enabled,
|
||||
sourceIps: device.ip ? [device.ip] : [],
|
||||
outbound: device.mode === "rules" ? "direct" : device.mode,
|
||||
}));
|
||||
}
|
||||
1585
src/server/index.js
Normal file
1585
src/server/index.js
Normal file
File diff suppressed because it is too large
Load Diff
50
src/server/ping.js
Normal file
50
src/server/ping.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
|
||||
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
|
||||
|
||||
import net from "node:net";
|
||||
import dns from "node:dns/promises";
|
||||
|
||||
const DEFAULT_TIMEOUT = 3000;
|
||||
|
||||
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||
const start = Date.now();
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let done = false;
|
||||
|
||||
const finish = (result) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.once("connect", () =>
|
||||
finish({ ok: true, latency: Date.now() - start }),
|
||||
);
|
||||
socket.once("timeout", () =>
|
||||
finish({ ok: false, latency: null, error: "timeout" }),
|
||||
);
|
||||
socket.once("error", (err) =>
|
||||
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||
);
|
||||
|
||||
try {
|
||||
socket.connect(port, host);
|
||||
} catch (err) {
|
||||
finish({ ok: false, latency: null, error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveHost(host) {
|
||||
if (net.isIP(host)) return host;
|
||||
try {
|
||||
const result = await dns.lookup(host);
|
||||
return result.address;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
325
src/server/routeMatcher.js
Normal file
325
src/server/routeMatcher.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// Простой симулятор роутинга sing-box.
|
||||
// Берём список customRules + safety/RU-direct и определяем, какое правило сработает.
|
||||
// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset
|
||||
// мы не можем точно сказать, попадает ли IP/домен в RU.
|
||||
|
||||
import net from "node:net";
|
||||
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
|
||||
|
||||
function ipv4ToInt(ip) {
|
||||
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
|
||||
);
|
||||
}
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
if (!net.isIP(ip)) return false;
|
||||
const [addr, maskStr] = String(cidr).split("/");
|
||||
if (!addr) return false;
|
||||
|
||||
if (net.isIPv4(ip) && net.isIPv4(addr)) {
|
||||
const mask = maskStr === undefined ? 32 : Number.parseInt(maskStr, 10);
|
||||
if (!Number.isInteger(mask) || mask < 0 || mask > 32) return false;
|
||||
const ipInt = ipv4ToInt(ip);
|
||||
const cidrInt = ipv4ToInt(addr);
|
||||
if (ipInt === null || cidrInt === null) return false;
|
||||
if (mask === 0) return true;
|
||||
const m = (~0 << (32 - mask)) >>> 0;
|
||||
return (ipInt & m) === (cidrInt & m);
|
||||
}
|
||||
// IPv6 — упрощённо: точное сравнение строк (без полноценной обработки)
|
||||
return false;
|
||||
}
|
||||
|
||||
const PRIVATE_CIDRS = [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
];
|
||||
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr));
|
||||
}
|
||||
|
||||
function normalizeCidr(ip) {
|
||||
const value = String(ip || "").trim();
|
||||
if (!value) return "";
|
||||
return value.includes("/") ? value : `${value}/32`;
|
||||
}
|
||||
|
||||
function deviceMatchesSourceIp(device, sourceIp) {
|
||||
if (!device?.ip || !sourceIp) return false;
|
||||
return ipInCidr(sourceIp, normalizeCidr(device.ip));
|
||||
}
|
||||
|
||||
function modeOutbound(mode, vpnTag) {
|
||||
if (mode === "vpn") return `${vpnTag} (VPN)`;
|
||||
if (mode === "direct" || mode === "block") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function likelyRuHost(host) {
|
||||
const value = String(host || "").toLowerCase();
|
||||
return value === "ru" || value.endsWith(".ru");
|
||||
}
|
||||
|
||||
function hostMatchesDomain(host, domain) {
|
||||
if (!host || !domain) return false;
|
||||
return host.toLowerCase() === domain.toLowerCase();
|
||||
}
|
||||
|
||||
function hostMatchesSuffix(host, suffix) {
|
||||
if (!host || !suffix) return false;
|
||||
const h = host.toLowerCase();
|
||||
const s = suffix.toLowerCase();
|
||||
return h === s || h.endsWith("." + s) || h.endsWith(s);
|
||||
}
|
||||
|
||||
function hostMatchesKeyword(host, keyword) {
|
||||
if (!host || !keyword) return false;
|
||||
return host.toLowerCase().includes(keyword.toLowerCase());
|
||||
}
|
||||
|
||||
function ruleMatches(rule, target) {
|
||||
const { host = "", ip = "", port, network } = target;
|
||||
|
||||
if (!rule?.enabled) return false;
|
||||
|
||||
const checks = [];
|
||||
|
||||
if (rule.domains?.length) {
|
||||
checks.push(rule.domains.some((d) => hostMatchesDomain(host, d)));
|
||||
}
|
||||
if (rule.domainSuffixes?.length) {
|
||||
checks.push(rule.domainSuffixes.some((d) => hostMatchesSuffix(host, d)));
|
||||
}
|
||||
if (rule.domainKeywords?.length) {
|
||||
checks.push(rule.domainKeywords.some((d) => hostMatchesKeyword(host, d)));
|
||||
}
|
||||
if (rule.ipCidrs?.length) {
|
||||
if (!ip) return false;
|
||||
checks.push(rule.ipCidrs.some((cidr) => ipInCidr(ip, cidr)));
|
||||
}
|
||||
if (rule.ports?.length) {
|
||||
if (port === undefined || port === null || port === "") return false;
|
||||
const p = Number(port);
|
||||
checks.push(
|
||||
rule.ports.some((portStr) => {
|
||||
const s = String(portStr).trim();
|
||||
if (s.includes("-")) {
|
||||
const [from, to] = s.split("-").map((x) => Number(x));
|
||||
return p >= from && p <= to;
|
||||
}
|
||||
return p === Number(s);
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (rule.networks?.length) {
|
||||
if (!network) return false;
|
||||
checks.push(rule.networks.includes(network));
|
||||
}
|
||||
|
||||
if (!checks.length) return false;
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Симулирует роутинг и возвращает результат.
|
||||
* @param {object} target { host, ip, port, network }
|
||||
* @param {Array} customRules
|
||||
* @param {object} options { routingRuDirect, vpnTag }
|
||||
*/
|
||||
export function matchRoute(target, customRules, options = {}) {
|
||||
const {
|
||||
routingRuDirect = true,
|
||||
vpnTag = "vpn-out",
|
||||
deviceProfiles = {
|
||||
defaultTransparentMode: "vpn",
|
||||
proxyDefaultMode: "vpn",
|
||||
devices: [],
|
||||
},
|
||||
} = options;
|
||||
const rules = Array.isArray(customRules) ? customRules : [];
|
||||
const inbound = target.inbound || TPROXY_INBOUND;
|
||||
const sourceIp = target.sourceIp || "";
|
||||
const devices = Array.isArray(deviceProfiles.devices)
|
||||
? deviceProfiles.devices
|
||||
: [];
|
||||
const matchedDevice = devices.find(
|
||||
(device) =>
|
||||
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
||||
);
|
||||
|
||||
// 1. private IP → direct
|
||||
if (target.ip && isPrivateIp(target.ip)) {
|
||||
return {
|
||||
matched: "system",
|
||||
ruleIndex: -1,
|
||||
ruleName: "private IP → direct",
|
||||
outbound: "direct",
|
||||
reason: `IP ${target.ip} приватный`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. global custom rules apply to every inbound before fallbacks.
|
||||
for (let i = 0; i < rules.length; i += 1) {
|
||||
const rule = rules[i];
|
||||
if (ruleMatches(rule, target)) {
|
||||
const outbound =
|
||||
rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
|
||||
return {
|
||||
matched: "custom",
|
||||
ruleIndex: i,
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
outbound,
|
||||
reason: "Совпадение по global custom rule",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
|
||||
if (routingRuDirect && likelyRuHost(target.host)) {
|
||||
return {
|
||||
matched: "geo",
|
||||
ruleIndex: -2,
|
||||
ruleName: "geosite-category-ru → direct",
|
||||
outbound: "direct",
|
||||
reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
|
||||
};
|
||||
}
|
||||
|
||||
// 4. transparent device defaults.
|
||||
if (inbound === TPROXY_INBOUND && matchedDevice) {
|
||||
const outbound = modeOutbound(matchedDevice.mode, vpnTag);
|
||||
if (outbound) {
|
||||
return {
|
||||
matched: "device-default",
|
||||
ruleIndex: -1,
|
||||
ruleId: matchedDevice.id,
|
||||
ruleName: `${matchedDevice.name} → ${matchedDevice.mode}`,
|
||||
outbound,
|
||||
reason: "Fallback устройства после global rules",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 5. explicit proxy default.
|
||||
if (inbound === MIXED_INBOUND) {
|
||||
const mode = deviceProfiles.proxyDefaultMode || "vpn";
|
||||
return {
|
||||
matched: "proxy-default",
|
||||
ruleIndex: -1,
|
||||
ruleName: `mixed-in default → ${mode}`,
|
||||
outbound: modeOutbound(mode, vpnTag) || `${vpnTag} (VPN)`,
|
||||
reason: "Fallback explicit HTTP/SOCKS proxy после global rules",
|
||||
};
|
||||
}
|
||||
|
||||
// 6. unknown transparent device default.
|
||||
if (inbound === TPROXY_INBOUND) {
|
||||
const mode = deviceProfiles.defaultTransparentMode || "vpn";
|
||||
return {
|
||||
matched: "transparent-default",
|
||||
ruleIndex: -1,
|
||||
ruleName: `transparent default → ${mode}`,
|
||||
outbound: modeOutbound(mode, vpnTag) || "direct",
|
||||
reason: "Fallback unknown transparent device после global rules",
|
||||
};
|
||||
}
|
||||
|
||||
// 7. final → direct
|
||||
return {
|
||||
matched: "final",
|
||||
ruleIndex: -3,
|
||||
ruleName: "final",
|
||||
outbound: "direct",
|
||||
reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Детектор конфликтов: ищет правила, перекрытые предыдущими.
|
||||
* Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим
|
||||
* по доменам/суффиксам/CIDR — отмечаем конфликт.
|
||||
*/
|
||||
export function detectRuleConflicts(rules) {
|
||||
const list = Array.isArray(rules) ? rules : [];
|
||||
const conflicts = [];
|
||||
|
||||
for (let i = 1; i < list.length; i += 1) {
|
||||
const cur = list[i];
|
||||
if (!cur?.enabled) continue;
|
||||
|
||||
for (let j = 0; j < i; j += 1) {
|
||||
const prev = list[j];
|
||||
if (!prev?.enabled) continue;
|
||||
|
||||
// Если outbound одинаковый — это не "конфликт", это дубликат
|
||||
const sameOutbound = prev.outbound === cur.outbound;
|
||||
|
||||
// Проверка перекрытия доменов
|
||||
const overlaps = [];
|
||||
|
||||
// Точные домены покрываются prev.suffix
|
||||
for (const d of cur.domains || []) {
|
||||
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) {
|
||||
overlaps.push({
|
||||
kind: "domain",
|
||||
value: d,
|
||||
by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}`,
|
||||
});
|
||||
}
|
||||
if ((prev.domains || []).includes(d)) {
|
||||
overlaps.push({ kind: "domain", value: d, by: "точный домен" });
|
||||
}
|
||||
}
|
||||
|
||||
// Суффиксы покрываются более общим суффиксом prev
|
||||
for (const s of cur.domainSuffixes || []) {
|
||||
if (
|
||||
(prev.domainSuffixes || []).some(
|
||||
(ps) => hostMatchesSuffix(s, ps) && ps !== s,
|
||||
)
|
||||
) {
|
||||
overlaps.push({
|
||||
kind: "suffix",
|
||||
value: s,
|
||||
by: "более общий суффикс",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CIDR
|
||||
for (const c of cur.ipCidrs || []) {
|
||||
if ((prev.ipCidrs || []).includes(c)) {
|
||||
overlaps.push({ kind: "cidr", value: c, by: "тот же CIDR" });
|
||||
}
|
||||
}
|
||||
|
||||
if (overlaps.length) {
|
||||
conflicts.push({
|
||||
ruleId: cur.id,
|
||||
ruleIndex: i,
|
||||
ruleName: cur.name,
|
||||
conflictWithId: prev.id,
|
||||
conflictWithIndex: j,
|
||||
conflictWithName: prev.name,
|
||||
severity: sameOutbound ? "info" : "warning",
|
||||
overlaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
94
src/server/sharedProxy.js
Normal file
94
src/server/sharedProxy.js
Normal file
@@ -0,0 +1,94 @@
|
||||
function normalizeControlUrl(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
||||
const url = new URL(withProtocol);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
throw new Error("Gateway URL must use http or https");
|
||||
}
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function proxyHostFromHeader(hostHeader) {
|
||||
const raw = String(hostHeader || "").trim();
|
||||
if (!raw) return "";
|
||||
if (raw.startsWith("[")) {
|
||||
const end = raw.indexOf("]");
|
||||
return end > 0 ? raw.slice(1, end) : "";
|
||||
}
|
||||
return raw.split(":")[0];
|
||||
}
|
||||
|
||||
function normalizeProxyInfo(proxy) {
|
||||
if (!proxy || typeof proxy !== "object") return null;
|
||||
const host = String(proxy.host || "").trim();
|
||||
const port = Number.parseInt(proxy.port, 10);
|
||||
const protocol = proxy.protocol === "http" ? "http" : "socks5";
|
||||
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return { host, port, protocol };
|
||||
}
|
||||
|
||||
export function buildSharedProxyInfo({
|
||||
appMode,
|
||||
proxyPort,
|
||||
running,
|
||||
hostHeader,
|
||||
sharedProxyHost,
|
||||
}) {
|
||||
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
||||
const port = Number.parseInt(proxyPort, 10);
|
||||
const available =
|
||||
appMode === "gateway" &&
|
||||
Boolean(running) &&
|
||||
host &&
|
||||
Number.isInteger(port) &&
|
||||
port > 0 &&
|
||||
port <= 65535;
|
||||
|
||||
const proxy = available
|
||||
? {
|
||||
host,
|
||||
port,
|
||||
protocol: "socks5",
|
||||
httpUrl: `http://${host}:${port}`,
|
||||
socksUrl: `socks5://${host}:${port}`,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
available,
|
||||
mode: appMode,
|
||||
proxy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
|
||||
const baseUrl = normalizeControlUrl(controlUrl);
|
||||
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `Gateway returned ${response.status}`);
|
||||
}
|
||||
if (!data.available) {
|
||||
throw new Error("Gateway shared proxy is not available");
|
||||
}
|
||||
|
||||
const sharedProxy = normalizeProxyInfo(data.proxy);
|
||||
if (!sharedProxy) {
|
||||
throw new Error("Gateway returned invalid shared proxy settings");
|
||||
}
|
||||
|
||||
return {
|
||||
sharedProxyEnabled: true,
|
||||
sharedProxyControlUrl: baseUrl,
|
||||
sharedProxy,
|
||||
};
|
||||
}
|
||||
377
src/server/singbox.js
Normal file
377
src/server/singbox.js
Normal file
@@ -0,0 +1,377 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
import {
|
||||
MIXED_INBOUND,
|
||||
TPROXY_INBOUND,
|
||||
normalizeCidr,
|
||||
readDeviceProfiles,
|
||||
} from "./devices.js";
|
||||
import { readClientSettings } from "./clientSettings.js";
|
||||
|
||||
const PROXY_TYPES = new Set([
|
||||
"vless",
|
||||
"vmess",
|
||||
"trojan",
|
||||
"shadowsocks",
|
||||
"hysteria2",
|
||||
]);
|
||||
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function findOutbound(subscriptionConfig, selectedTag) {
|
||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||
? subscriptionConfig.outbounds
|
||||
: [];
|
||||
const exact = outbounds.find(
|
||||
(outbound) =>
|
||||
outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type),
|
||||
);
|
||||
if (exact) return exact;
|
||||
|
||||
const trimmedTag = String(selectedTag || "").trim();
|
||||
return outbounds.find(
|
||||
(outbound) =>
|
||||
String(outbound.tag || "").trim() === trimmedTag &&
|
||||
PROXY_TYPES.has(outbound.type),
|
||||
);
|
||||
}
|
||||
|
||||
function readCustomRuleSets() {
|
||||
try {
|
||||
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(settings.customRuleSetsPath, "utf8"),
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ruleSetDownloadDetour(vpnTag) {
|
||||
const detour = String(settings.ruleSetDownloadDetour || "vpn").trim();
|
||||
if (!detour || detour === "vpn") return vpnTag;
|
||||
return detour;
|
||||
}
|
||||
|
||||
function ruleSets(customRuleSets = [], vpnTag = "direct") {
|
||||
const downloadDetour = ruleSetDownloadDetour(vpnTag);
|
||||
const builtIn = settings.routingRuDirect
|
||||
? [
|
||||
{
|
||||
type: "remote",
|
||||
tag: "geoip-ru",
|
||||
format: "binary",
|
||||
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
|
||||
download_detour: downloadDetour,
|
||||
},
|
||||
{
|
||||
type: "remote",
|
||||
tag: "geosite-category-ru",
|
||||
format: "binary",
|
||||
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
|
||||
download_detour: downloadDetour,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const custom = (Array.isArray(customRuleSets) ? customRuleSets : [])
|
||||
.filter((rs) => rs.tag && rs.url)
|
||||
.map((rs) => ({
|
||||
type: "remote",
|
||||
tag: String(rs.tag).trim(),
|
||||
format: rs.format || "binary",
|
||||
url: String(rs.url).trim(),
|
||||
download_detour: downloadDetour,
|
||||
}));
|
||||
|
||||
// Пользовательские rule-sets не должны дублировать встроенные
|
||||
const builtInTags = new Set(builtIn.map((rs) => rs.tag));
|
||||
const merged = [
|
||||
...builtIn,
|
||||
...custom.filter((rs) => !builtInTags.has(rs.tag)),
|
||||
];
|
||||
return merged;
|
||||
}
|
||||
|
||||
function uniqueClean(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parsePorts(values) {
|
||||
return uniqueClean(values)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
|
||||
}
|
||||
|
||||
function toSingboxRule(customRule, vpnTag, baseRule = {}) {
|
||||
if (!customRule?.enabled) return null;
|
||||
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
|
||||
|
||||
const rule = { ...baseRule };
|
||||
const domains = uniqueClean(customRule.domains);
|
||||
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
|
||||
const domainKeywords = uniqueClean(customRule.domainKeywords);
|
||||
const ipCidrs = uniqueClean(customRule.ipCidrs);
|
||||
const ports = parsePorts(customRule.ports);
|
||||
const networks = uniqueClean(customRule.networks).filter((network) =>
|
||||
["tcp", "udp"].includes(network),
|
||||
);
|
||||
|
||||
if (domains.length) rule.domain = domains;
|
||||
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
|
||||
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
|
||||
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
|
||||
if (ports.length) rule.port = ports;
|
||||
if (networks.length) rule.network = networks;
|
||||
|
||||
const ruleSetsRef = uniqueClean(customRule.ruleSets);
|
||||
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
|
||||
|
||||
if (
|
||||
!rule.domain &&
|
||||
!rule.domain_suffix &&
|
||||
!rule.domain_keyword &&
|
||||
!rule.ip_cidr &&
|
||||
!rule.port &&
|
||||
!rule.network &&
|
||||
!rule.rule_set
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
|
||||
return rule;
|
||||
}
|
||||
|
||||
function customRouteRules(customRules, vpnTag, baseRule = {}) {
|
||||
return (Array.isArray(customRules) ? customRules : [])
|
||||
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
|
||||
|
||||
function modeOutbound(mode, vpnTag) {
|
||||
if (mode === "vpn") return vpnTag;
|
||||
if (mode === "direct" || mode === "block") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function deviceDefaultRouteRule(device, vpnTag) {
|
||||
if (!device?.enabled) return null;
|
||||
const outbound = modeOutbound(device.mode, vpnTag);
|
||||
if (!outbound) return null;
|
||||
|
||||
const cidr = normalizeCidr(device.ip);
|
||||
if (!cidr) return null;
|
||||
|
||||
return {
|
||||
inbound: [TPROXY_INBOUND],
|
||||
source_ip_cidr: [cidr],
|
||||
outbound,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceDefaultRouteRules(devices, vpnTag) {
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
.map((device) => deviceDefaultRouteRule(device, vpnTag))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inboundDefaultRule(inbound, mode, vpnTag) {
|
||||
const outbound = modeOutbound(mode, vpnTag);
|
||||
if (!outbound) return null;
|
||||
return { inbound: [inbound], outbound };
|
||||
}
|
||||
|
||||
function ruDirectRule() {
|
||||
if (!settings.routingRuDirect) return null;
|
||||
return {
|
||||
rule_set: ["geoip-ru", "geosite-category-ru"],
|
||||
outbound: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const rules = [
|
||||
{
|
||||
ip_is_private: true,
|
||||
outbound: "direct",
|
||||
},
|
||||
];
|
||||
|
||||
// Global rules apply to every inbound before contextual fallbacks.
|
||||
rules.push(...customRouteRules(customRules, vpnTag));
|
||||
|
||||
const ruRule = ruDirectRule();
|
||||
if (ruRule) rules.push(ruRule);
|
||||
|
||||
if (includeTransparent) {
|
||||
// Device defaults are only transparent-gateway fallbacks after global rules.
|
||||
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
|
||||
}
|
||||
|
||||
const proxyFallback = inboundDefaultRule(
|
||||
MIXED_INBOUND,
|
||||
deviceProfiles.proxyDefaultMode,
|
||||
vpnTag,
|
||||
);
|
||||
if (proxyFallback) rules.push(proxyFallback);
|
||||
|
||||
if (includeTransparent) {
|
||||
const transparentFallback = inboundDefaultRule(
|
||||
TPROXY_INBOUND,
|
||||
deviceProfiles.defaultTransparentMode,
|
||||
vpnTag,
|
||||
);
|
||||
if (transparentFallback) rules.push(transparentFallback);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function sharedProxyOutbound(sharedProxy) {
|
||||
if (!sharedProxy?.host || !sharedProxy?.port) return null;
|
||||
if (sharedProxy.protocol === "http") {
|
||||
return {
|
||||
type: "http",
|
||||
tag: "shared-proxy",
|
||||
server: sharedProxy.host,
|
||||
server_port: sharedProxy.port,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "socks",
|
||||
tag: "shared-proxy",
|
||||
server: sharedProxy.host,
|
||||
server_port: sharedProxy.port,
|
||||
version: "5",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGatewayConfig(
|
||||
subscriptionConfig,
|
||||
selectedTag,
|
||||
{ bypassAll = false } = {},
|
||||
) {
|
||||
const customRuleSets = readCustomRuleSets();
|
||||
const clientMode = settings.appMode === "client";
|
||||
const clientSettings = clientMode ? readClientSettings() : null;
|
||||
const sharedOutbound =
|
||||
clientMode && clientSettings?.sharedProxyEnabled
|
||||
? sharedProxyOutbound(clientSettings.sharedProxy)
|
||||
: null;
|
||||
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
|
||||
const selectedOutbound = sharedOutbound
|
||||
? null
|
||||
: findOutbound(subscriptionConfig, selectedTag);
|
||||
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
|
||||
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||
}
|
||||
|
||||
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
|
||||
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
|
||||
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = "xudp";
|
||||
}
|
||||
|
||||
const clientOutbound = sharedOutbound
|
||||
? sharedOutbound.tag
|
||||
: clientSettings?.homeBypassEnabled
|
||||
? "direct"
|
||||
: vpnOutbound.tag;
|
||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||
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: mixedProxyPort,
|
||||
sniff: true,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
log: {
|
||||
level: settings.logLevel,
|
||||
timestamp: true,
|
||||
},
|
||||
experimental: {
|
||||
cache_file: {
|
||||
enabled: true,
|
||||
path: settings.cachePath,
|
||||
},
|
||||
},
|
||||
dns: {
|
||||
independent_cache: true,
|
||||
},
|
||||
inbounds,
|
||||
outbounds: [
|
||||
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
|
||||
{ type: "direct", tag: "direct" },
|
||||
{ type: "block", tag: "block" },
|
||||
],
|
||||
route: {
|
||||
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rules: bypassAll
|
||||
? [{ ip_is_private: true, outbound: "direct" }]
|
||||
: clientMode
|
||||
? proxyOnlyRules
|
||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||
includeTransparent: !clientMode,
|
||||
}),
|
||||
final: "direct",
|
||||
auto_detect_interface: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function writeSingboxConfig(config) {
|
||||
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settings.configPath,
|
||||
JSON.stringify(config, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function readSingboxConfig() {
|
||||
if (!fs.existsSync(settings.configPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settings.configPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSingboxConfig() {
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
fs.rmSync(settings.configPath);
|
||||
}
|
||||
}
|
||||
169
src/server/subscription.js
Normal file
169
src/server/subscription.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { settings } from './config.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
|
||||
export function getHwid() {
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
if (fs.existsSync(settings.hwidPath)) {
|
||||
return fs.readFileSync(settings.hwidPath, 'utf8').trim();
|
||||
}
|
||||
const hwid = crypto.randomBytes(8).toString('hex');
|
||||
fs.writeFileSync(settings.hwidPath, hwid, 'utf8');
|
||||
return hwid;
|
||||
}
|
||||
|
||||
export function subscriptionHeaders() {
|
||||
return {
|
||||
'user-agent': 'singbox',
|
||||
'x-hwid': getHwid(),
|
||||
'x-device-os': process.platform,
|
||||
'x-ver-os': process.version,
|
||||
'x-device-model': settings.appName,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserInfo(headerValue) {
|
||||
const result = {};
|
||||
if (!headerValue) return result;
|
||||
|
||||
for (const part of String(headerValue).split(';')) {
|
||||
const [key, value] = part.trim().split('=', 2);
|
||||
if (!key || value === undefined) continue;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(parsed)) result[key] = parsed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseVlessUrl(rawUrl) {
|
||||
if (!rawUrl.startsWith('vless://')) {
|
||||
throw new Error('VLESS URL must start with vless://');
|
||||
}
|
||||
|
||||
const parsed = new URL(rawUrl);
|
||||
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
|
||||
const uuid = decodeURIComponent(parsed.username || '');
|
||||
const server = parsed.hostname;
|
||||
const serverPort = Number.parseInt(parsed.port || '443', 10);
|
||||
const publicKey = parsed.searchParams.get('pbk') || '';
|
||||
const shortId = parsed.searchParams.get('sid') || '';
|
||||
const serverName = parsed.searchParams.get('sni') || server;
|
||||
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
|
||||
const flow = parsed.searchParams.get('flow') || '';
|
||||
|
||||
if (!uuid || !server || !serverPort) {
|
||||
throw new Error('VLESS URL misses uuid, host or port');
|
||||
}
|
||||
|
||||
if (!publicKey || !shortId) {
|
||||
throw new Error('VLESS REALITY parameters pbk and sid are required');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'vless',
|
||||
tag,
|
||||
server,
|
||||
server_port: serverPort,
|
||||
uuid,
|
||||
flow,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: serverName,
|
||||
utls: {
|
||||
enabled: true,
|
||||
fingerprint,
|
||||
},
|
||||
reality: {
|
||||
enabled: true,
|
||||
public_key: publicKey,
|
||||
short_id: shortId,
|
||||
},
|
||||
},
|
||||
packet_encoding: 'xudp',
|
||||
};
|
||||
}
|
||||
|
||||
function maybeDecodeBase64(content) {
|
||||
const compact = content.trim().replace(/\s+/g, '');
|
||||
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(compact, 'base64').toString('utf8');
|
||||
if (decoded.includes('vless://') || decoded.includes('{')) return decoded;
|
||||
} catch {}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function parseSubscriptionBody(body) {
|
||||
let parsedConfig = null;
|
||||
|
||||
try {
|
||||
parsedConfig = JSON.parse(body);
|
||||
} catch {
|
||||
const decoded = maybeDecodeBase64(body);
|
||||
const links = decoded
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('vless://'));
|
||||
|
||||
if (!links.length) {
|
||||
throw new Error('Subscription does not contain JSON config or VLESS links');
|
||||
}
|
||||
|
||||
parsedConfig = {
|
||||
outbounds: links.map(parseVlessUrl),
|
||||
};
|
||||
}
|
||||
|
||||
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||
const servers = outbounds
|
||||
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||
.map((outbound) => ({
|
||||
tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`,
|
||||
type: outbound.type,
|
||||
server: outbound.server || 'unknown',
|
||||
server_port: outbound.server_port || 443,
|
||||
}));
|
||||
|
||||
if (!servers.length) {
|
||||
throw new Error('No supported proxy outbounds found in subscription');
|
||||
}
|
||||
|
||||
return { config: parsedConfig, servers };
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url) {
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid subscription URL');
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Subscription URL must use http or https');
|
||||
}
|
||||
|
||||
const response = await fetch(parsedUrl, {
|
||||
headers: subscriptionHeaders(),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||
const parsed = parseSubscriptionBody(body);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
userInfo,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
575
src/web/App.jsx
Normal file
575
src/web/App.jsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
import { api } from './api.js';
|
||||
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';
|
||||
import { SettingsPage } from './components/SettingsPage.jsx';
|
||||
import { ConfigViewer } from './components/ConfigViewer.jsx';
|
||||
import { Toasts } from './components/Toasts.jsx';
|
||||
|
||||
const ROLLBACK_WINDOW_MS = 12_000;
|
||||
|
||||
function getInitialPage() {
|
||||
const hash = window.location.hash.replace('#/', '').replace('#', '');
|
||||
const valid = ['overview', 'servers', 'routing', 'logs', 'settings'];
|
||||
return valid.includes(hash) ? hash : 'overview';
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState(getInitialPage());
|
||||
const [state, setState] = useState(null);
|
||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||
const [servers, setServers] = useState([]);
|
||||
const [customRules, setCustomRules] = useState([]);
|
||||
const [devicesConfig, setDevicesConfig] = useState({
|
||||
defaultTransparentMode: 'vpn',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: [],
|
||||
});
|
||||
const [clientSettings, setClientSettings] = useState({
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [pings, setPings] = useState({});
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [applyStatus, setApplyStatus] = useState('idle'); // idle | applying | error
|
||||
const [rollbackOffer, setRollbackOffer] = useState(null);
|
||||
|
||||
const rulesDirtyRef = useRef(false);
|
||||
const rulesSaveTimerRef = useRef(null);
|
||||
const rulesRevisionRef = useRef(0);
|
||||
const rollbackTimerRef = useRef(null);
|
||||
|
||||
function pushToast(toast) {
|
||||
const id = `t-${Date.now()}-${Math.random()}`;
|
||||
setToasts((prev) => [...prev, { id, ...toast }]);
|
||||
}
|
||||
function dismissToast(id) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function navigate(p) {
|
||||
setPage(p);
|
||||
window.location.hash = `#/${p}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onHash() { setPage(getInitialPage()); }
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
|
||||
async function loadState() {
|
||||
const data = await api.state();
|
||||
setState(data);
|
||||
setServers(data.servers || []);
|
||||
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
||||
setDevicesConfig(data.devicesConfig || {
|
||||
defaultTransparentMode: 'vpn',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadState().catch((err) => setError(err.message));
|
||||
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
||||
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);
|
||||
}, []);
|
||||
|
||||
async function withBusy(label, fn, { quiet = false } = {}) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await fn();
|
||||
if (!quiet && label) pushToast({ kind: 'success', title: label });
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
|
||||
throw err;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// === Subscription ===
|
||||
async function fetchSubscription() {
|
||||
return withBusy('Подписка обновлена', async () => {
|
||||
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
|
||||
setServers(data.servers || []);
|
||||
if (!selectedTag && data.servers?.length) {
|
||||
setSelectedTag(data.servers[0].tag);
|
||||
setPendingTag(data.servers[0].tag);
|
||||
}
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
async function forgetSubscription() {
|
||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||
return withBusy('Подписка удалена', async () => {
|
||||
await api.subscription.forget();
|
||||
setSubscriptionUrl('');
|
||||
setServers([]);
|
||||
setSelectedTag('');
|
||||
setPendingTag('');
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Apply with rollback offer ===
|
||||
async function applyServer(tag) {
|
||||
const target = tag || selectedTag;
|
||||
if (!target) return;
|
||||
const previous = state?.selectedTag;
|
||||
setApplyStatus('applying');
|
||||
try {
|
||||
await withBusy('Сервер применён', async () => {
|
||||
await api.apply(target);
|
||||
await loadState();
|
||||
});
|
||||
setApplyStatus('idle');
|
||||
|
||||
if (previous && previous !== target) {
|
||||
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
|
||||
}
|
||||
} catch {
|
||||
setApplyStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback() {
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
setRollbackOffer(null);
|
||||
return withBusy('Откат выполнен', async () => {
|
||||
const data = await api.rollback();
|
||||
setSelectedTag(data.selectedTag);
|
||||
setPendingTag(data.selectedTag);
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === sing-box control ===
|
||||
async function stopSingbox() {
|
||||
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
|
||||
}
|
||||
async function restartSingbox() {
|
||||
return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); });
|
||||
}
|
||||
async function clearConfig() {
|
||||
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
|
||||
return withBusy('Config сброшен', async () => {
|
||||
await api.singbox.clear();
|
||||
setSelectedTag('');
|
||||
setPendingTag('');
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleBypass() {
|
||||
const next = !state?.bypassMode;
|
||||
return withBusy(
|
||||
next ? 'Обход правил включён — весь трафик напрямую' : 'Обход правил отключён',
|
||||
async () => {
|
||||
await api.bypass(next);
|
||||
await loadState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function flushDirectCache() {
|
||||
return withBusy('Bypass-кэш сброшен', async () => {
|
||||
await api.directCache.flush();
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Devices ===
|
||||
async function saveDevicesConfig(nextConfig) {
|
||||
try {
|
||||
const data = await api.devices.save(nextConfig);
|
||||
setDevicesConfig({
|
||||
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'vpn',
|
||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: [
|
||||
...devicesConfig.devices,
|
||||
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
|
||||
],
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDevice(id, patch) {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function removeDevice(id) {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.filter((d) => d.id !== id),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDeviceDefaults(patch) {
|
||||
const nextConfig = { ...devicesConfig, ...patch };
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
async function saveClientSettings(nextSettings) {
|
||||
return withBusy(null, async () => {
|
||||
const data = await api.clientSettings.save(nextSettings);
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
await loadState();
|
||||
}, { quiet: true });
|
||||
}
|
||||
|
||||
async function checkSharedProxy(url) {
|
||||
return withBusy('Общий proxy подключён', async () => {
|
||||
const data = await api.clientSettings.checkSharedProxy(url);
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Rules CRUD ===
|
||||
function emptyRule() {
|
||||
return {
|
||||
id: `rule-${Date.now()}`,
|
||||
name: 'Новое правило',
|
||||
enabled: true,
|
||||
outbound: 'direct',
|
||||
domains: [], domainSuffixes: [], domainKeywords: [],
|
||||
ipCidrs: [], ports: [], networks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function queueRulesSave(nextRules) {
|
||||
rulesDirtyRef.current = true;
|
||||
const revision = rulesRevisionRef.current + 1;
|
||||
rulesRevisionRef.current = revision;
|
||||
setRulesSaveStatus('pending');
|
||||
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
|
||||
}
|
||||
|
||||
async function saveRules(nextRules = customRules, options = {}) {
|
||||
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
||||
setError('');
|
||||
setRulesSaveStatus('saving');
|
||||
try {
|
||||
const data = await api.rules.save(nextRules);
|
||||
if (rulesRevisionRef.current === revision) {
|
||||
rulesDirtyRef.current = false;
|
||||
setCustomRules(data.rules || []);
|
||||
setRulesSaveStatus('saved');
|
||||
await loadState();
|
||||
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
|
||||
} else {
|
||||
setRulesSaveStatus('pending');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setRulesSaveStatus('error');
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function saveRulesNow() {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
rulesDirtyRef.current = true;
|
||||
const revision = rulesRevisionRef.current + 1;
|
||||
rulesRevisionRef.current = revision;
|
||||
saveRules(customRules, { silent: false, revision });
|
||||
}
|
||||
|
||||
function updateRule(id, patch) {
|
||||
setCustomRules((rules) => {
|
||||
const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r));
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function addRule() {
|
||||
setCustomRules((rules) => {
|
||||
const next = [emptyRule(), ...rules];
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function addRuleFromTemplate(tpl) {
|
||||
setCustomRules((rules) => {
|
||||
const next = [tpl, ...rules];
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function removeRule(id) {
|
||||
setCustomRules((rules) => {
|
||||
const next = rules.filter((r) => r.id !== id);
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function reorderRules(next) {
|
||||
setCustomRules(next);
|
||||
queueRulesSave(next);
|
||||
}
|
||||
|
||||
// === Computed ===
|
||||
const status = useMemo(() => {
|
||||
if (applyStatus === 'applying') return 'applying';
|
||||
if (applyStatus === 'error') return 'error';
|
||||
if (state?.singboxRunning) return 'running';
|
||||
if (state?.configExists) return 'stopped';
|
||||
return 'no_config';
|
||||
}, [state, applyStatus]);
|
||||
|
||||
const activeServer = useMemo(
|
||||
() => 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(
|
||||
state?.devicesUpdatedAt &&
|
||||
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
|
||||
);
|
||||
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
|
||||
const dirtyRouting = dirtyRules || dirtyDevices;
|
||||
const dirty = dirtyRouting || dirtyServer;
|
||||
|
||||
const sidebarBadges = {
|
||||
routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
|
||||
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
|
||||
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
return (
|
||||
<div className="app">
|
||||
<Topbar
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
dirty={dirty}
|
||||
onRestart={restartSingbox}
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<main className="app-main">
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
busy={busy}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
clientSettings={clientSettings}
|
||||
onSaveClientSettings={saveClientSettings}
|
||||
onCheckSharedProxy={checkSharedProxy}
|
||||
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' && !isClientMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
selectedTag={selectedTag}
|
||||
setSelectedTag={setSelectedTag}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
busy={busy}
|
||||
onApply={applyServer}
|
||||
onRollback={rollback}
|
||||
pings={pings}
|
||||
setPings={setPings}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && !isClientMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
busy={busy}
|
||||
onAdd={addRule}
|
||||
onAddTemplate={addRuleFromTemplate}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onSaveNow={saveRulesNow}
|
||||
onReorder={reorderRules}
|
||||
devicesConfig={devicesConfig}
|
||||
onUpdateDeviceDefaults={updateDeviceDefaults}
|
||||
onAddDevice={addDevice}
|
||||
onUpdateDevice={updateDevice}
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && !isClientMode && (
|
||||
<SettingsPage
|
||||
state={state}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
busy={busy}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onClearConfig={clearConfig}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sticky bar — для routing/servers */}
|
||||
{(page === 'routing' && dirtyRouting) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
<strong>
|
||||
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
|
||||
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
|
||||
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
|
||||
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
|
||||
</strong>
|
||||
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
{rulesSaveStatus !== 'saved' && (
|
||||
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
|
||||
)}
|
||||
{state?.selectedTag && (
|
||||
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
|
||||
Применить config
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(page === 'servers' && dirtyServer) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className="dot warning" />
|
||||
<strong>Сервер не применён</strong>
|
||||
<small className="muted">Выбран: {pendingTag}</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
|
||||
<button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!isClientMode && (
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||
<Toasts items={toasts} onDismiss={dismissToast} />
|
||||
|
||||
{rollbackOffer && (
|
||||
<div className="toasts">
|
||||
<div className="toast warning">
|
||||
<span className="dot warning" style={{ marginTop: 4 }} />
|
||||
<div className="body">
|
||||
<strong>Сервер применён</strong>
|
||||
<small>Можно откатиться к «{rollbackOffer.to}»</small>
|
||||
<button className="btn btn-link" onClick={rollback} style={{ padding: 0, marginTop: 4 }}>
|
||||
↶ Откатить
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setRollbackOffer(null)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
127
src/web/api.js
Normal file
127
src/web/api.js
Normal file
@@ -0,0 +1,127 @@
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || (data && data.success === false)) {
|
||||
throw new Error(
|
||||
data?.error || `Запрос ${url} завершился ошибкой ${response.status}`,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
state: () => request("/api/state"),
|
||||
config: () => request("/api/config"),
|
||||
|
||||
rules: {
|
||||
get: () => request("/api/rules"),
|
||||
save: (rules) =>
|
||||
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
|
||||
conflicts: () => request("/api/rules/conflicts"),
|
||||
},
|
||||
|
||||
deviceRules: {
|
||||
get: () => request("/api/device-rules"),
|
||||
save: (deviceRules) =>
|
||||
request("/api/device-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ deviceRules }),
|
||||
}),
|
||||
},
|
||||
|
||||
devices: {
|
||||
get: () => request("/api/devices"),
|
||||
save: (devicesConfig) =>
|
||||
request("/api/devices", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(devicesConfig),
|
||||
}),
|
||||
},
|
||||
|
||||
clientSettings: {
|
||||
get: () => request("/api/client-settings"),
|
||||
save: (clientSettings) =>
|
||||
request("/api/client-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ clientSettings }),
|
||||
}),
|
||||
checkSharedProxy: (url) =>
|
||||
request("/api/client-settings/shared-proxy/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
get: () => request("/api/rule-sets"),
|
||||
save: (ruleSets) =>
|
||||
request("/api/rule-sets", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ruleSets }),
|
||||
}),
|
||||
lookup: (tag, url) =>
|
||||
request("/api/rule-sets/lookup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tag, url }),
|
||||
}),
|
||||
sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"),
|
||||
},
|
||||
|
||||
subscription: {
|
||||
fetch: (url) =>
|
||||
request("/api/subscription/fetch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
forget: () => request("/api/subscription", { method: "DELETE" }),
|
||||
},
|
||||
|
||||
apply: (selectedTag) =>
|
||||
request("/api/apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ selectedTag }),
|
||||
}),
|
||||
rollback: () => request("/api/apply/rollback", { method: "POST" }),
|
||||
|
||||
singbox: {
|
||||
stop: () => request("/api/singbox/stop", { method: "POST" }),
|
||||
restart: () => request("/api/singbox/restart", { method: "POST" }),
|
||||
clear: () => request("/api/singbox/clear", { method: "POST" }),
|
||||
},
|
||||
|
||||
servers: {
|
||||
ping: (host, port) =>
|
||||
request("/api/servers/ping", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ host, port }),
|
||||
}),
|
||||
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
|
||||
},
|
||||
|
||||
bypass: (enabled) =>
|
||||
request("/api/bypass", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
|
||||
directCache: {
|
||||
get: () => request("/api/direct-cache"),
|
||||
flush: () => request("/api/direct-cache", { method: "DELETE" }),
|
||||
},
|
||||
|
||||
route: {
|
||||
check: ({ host, ip, port, network, sourceIp, inbound }) =>
|
||||
request("/api/route/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
|
||||
}),
|
||||
},
|
||||
|
||||
configValidate: () => request("/api/config/validate", { method: "POST" }),
|
||||
};
|
||||
61
src/web/components/ChipsInput.jsx
Normal file
61
src/web/components/ChipsInput.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Chip input. Items separated by Enter, comma, или space (для CIDR/портов).
|
||||
* Невалидные элементы помечаются красным.
|
||||
*/
|
||||
export function ChipsInput({ value = [], onChange, placeholder = '', validate, splitter = /[\s,]/ }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
function commit(text) {
|
||||
const parts = String(text).split(splitter).map((p) => p.trim()).filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
const next = Array.from(new Set([...value, ...parts]));
|
||||
onChange(next);
|
||||
setDraft('');
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
onChange(value.filter((v) => v !== item));
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
if (draft.trim()) commit(draft);
|
||||
} else if (e.key === 'Backspace' && !draft && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(e) {
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text && splitter.test(text)) {
|
||||
e.preventDefault();
|
||||
commit(text);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chips" onClick={(e) => e.currentTarget.querySelector('input')?.focus()}>
|
||||
{value.map((item) => {
|
||||
const invalid = validate ? !validate(item) : false;
|
||||
return (
|
||||
<span key={item} className={`chip ${invalid ? 'error' : ''}`}>
|
||||
{item}
|
||||
<button type="button" onClick={() => remove(item)} title="Убрать">×</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
className="chip-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onBlur={() => draft.trim() && commit(draft)}
|
||||
placeholder={value.length ? '' : placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
src/web/components/ClientOverviewPage.jsx
Normal file
365
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
import { resolveClientRoute } from '../utils/clientRoute.js';
|
||||
|
||||
function CopyValue({ value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="client-copy" type="button" onClick={copy}>
|
||||
<span>{value}</span>
|
||||
<strong>{copied ? 'OK' : 'Copy'}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPanel({ route, state }) {
|
||||
const statusLabel = {
|
||||
connected: 'Работает',
|
||||
stopped: 'Остановлен',
|
||||
empty: 'Не настроен',
|
||||
}[route.status];
|
||||
|
||||
return (
|
||||
<section className={`client-status-panel ${route.status}`}>
|
||||
<div className="client-status-main">
|
||||
<span className={`client-status-dot ${route.status}`} />
|
||||
<div>
|
||||
<div className="client-eyebrow">Текущий маршрут</div>
|
||||
<h1>{route.title}</h1>
|
||||
<p>{route.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-status-facts">
|
||||
<div>
|
||||
<small>Куда</small>
|
||||
<strong>{route.target}</strong>
|
||||
<span>{route.targetDetail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<small>Локальный proxy</small>
|
||||
<strong>{route.localProxy}</strong>
|
||||
<span>HTTP и SOCKS5</span>
|
||||
</div>
|
||||
<div>
|
||||
<small>Сервис</small>
|
||||
<strong>{statusLabel}</strong>
|
||||
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteLine({ route }) {
|
||||
return (
|
||||
<div className="client-route-line">
|
||||
{route.path.map((item, index) => (
|
||||
<React.Fragment key={`${item}-${index}`}>
|
||||
<span>{item}</span>
|
||||
{index < route.path.length - 1 && <b>→</b>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({ active, selected, title, subtitle, onClick, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`client-mode-button ${selected ? 'selected' : ''} ${active ? 'active' : ''}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
<span>{subtitle}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewaySettings({ settings, busy, onCheck }) {
|
||||
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
|
||||
const sharedProxy = settings?.sharedProxy;
|
||||
|
||||
useEffect(() => {
|
||||
setDraftUrl(settings?.sharedProxyControlUrl || '');
|
||||
}, [settings?.sharedProxyControlUrl]);
|
||||
|
||||
return (
|
||||
<div className="client-mode-settings">
|
||||
<div className="field">
|
||||
<label className="field-label">Адрес gateway UI</label>
|
||||
<div className="client-inline-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="http://192.168.50.111:3456"
|
||||
value={draftUrl}
|
||||
onChange={(e) => setDraftUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !draftUrl} onClick={() => onCheck(draftUrl)}>
|
||||
Подключить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{sharedProxy && (
|
||||
<div className="client-current-target">
|
||||
<small>Найден общий proxy</small>
|
||||
<strong>{sharedProxy.host}:{sharedProxy.port}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VpnSettings({
|
||||
state,
|
||||
servers,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
}) {
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const activeServer = servers.find((server) => server.tag === selected);
|
||||
|
||||
return (
|
||||
<div className="client-mode-settings">
|
||||
<div className="field">
|
||||
<label className="field-label">Подписка или VLESS</label>
|
||||
<div className="client-inline-form">
|
||||
<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-secondary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">VPN-сервер</label>
|
||||
<div className="client-inline-form">
|
||||
<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-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
|
||||
Подключить
|
||||
</button>
|
||||
</div>
|
||||
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectSettings({ busy, onEnable }) {
|
||||
return (
|
||||
<div className="client-mode-settings direct">
|
||||
<div>
|
||||
<strong>Прямой режим</strong>
|
||||
<p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
|
||||
Включить напрямую
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxySettings({ 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 parsed = Number.parseInt(draftPort, 10);
|
||||
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
|
||||
const dirty = !invalid && parsed !== port;
|
||||
|
||||
return (
|
||||
<aside className="client-side-panel">
|
||||
<div>
|
||||
<div className="client-panel-title">Адрес для приложений</div>
|
||||
<div className="client-copy-stack">
|
||||
<CopyValue value={`http://127.0.0.1:${port}`} />
|
||||
<CopyValue value={`socks5://127.0.0.1:${port}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Порт proxy</label>
|
||||
<div className="client-port-row">
|
||||
<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 || !dirty}
|
||||
onClick={() => onSave({ ...settings, proxyPort: parsed })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<small className={invalid ? 'field-error' : 'field-hint'}>{range.start}–{range.end}</small>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
activeServer,
|
||||
busy,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
clientSettings,
|
||||
onSaveClientSettings,
|
||||
onCheckSharedProxy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
}) {
|
||||
const route = useMemo(
|
||||
() => resolveClientRoute({ state, activeServer }),
|
||||
[state, activeServer],
|
||||
);
|
||||
const [setupMode, setSetupMode] = useState(route.mode === 'none' ? 'gateway' : route.mode);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.mode !== 'none') setSetupMode(route.mode);
|
||||
}, [route.mode]);
|
||||
|
||||
function enableDirect() {
|
||||
return onSaveClientSettings({
|
||||
...clientSettings,
|
||||
homeBypassEnabled: true,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
function selectGateway() {
|
||||
setSetupMode('gateway');
|
||||
if (clientSettings?.sharedProxyControlUrl) {
|
||||
return onCheckSharedProxy(clientSettings.sharedProxyControlUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectVpn() {
|
||||
setSetupMode('vpn');
|
||||
if (state?.selectedTag) {
|
||||
return onApply(state.selectedTag);
|
||||
}
|
||||
return onSaveClientSettings({
|
||||
...clientSettings,
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="client-dashboard">
|
||||
<StatusPanel route={route} state={state} />
|
||||
<RouteLine route={route} />
|
||||
|
||||
<section className="client-workspace">
|
||||
<div className="client-main-panel">
|
||||
<div className="client-mode-grid">
|
||||
<ModeButton
|
||||
active={route.mode === 'gateway'}
|
||||
selected={setupMode === 'gateway'}
|
||||
title="Общий gateway"
|
||||
subtitle={clientSettings?.sharedProxy ? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}` : 'серверная proxy'}
|
||||
disabled={busy}
|
||||
onClick={selectGateway}
|
||||
/>
|
||||
<ModeButton
|
||||
active={route.mode === 'vpn'}
|
||||
selected={setupMode === 'vpn'}
|
||||
title="Локальный VPN"
|
||||
subtitle={state?.selectedTag || 'выбрать сервер'}
|
||||
disabled={busy}
|
||||
onClick={selectVpn}
|
||||
/>
|
||||
<ModeButton
|
||||
active={route.mode === 'direct'}
|
||||
selected={setupMode === 'direct'}
|
||||
title="Напрямую"
|
||||
subtitle="без VPN"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setSetupMode('direct');
|
||||
enableDirect();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{setupMode === 'gateway' && (
|
||||
<GatewaySettings
|
||||
settings={clientSettings}
|
||||
busy={busy}
|
||||
onCheck={onCheckSharedProxy}
|
||||
/>
|
||||
)}
|
||||
{setupMode === 'vpn' && (
|
||||
<VpnSettings
|
||||
state={state}
|
||||
servers={servers}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
busy={busy}
|
||||
onFetchSubscription={onFetchSubscription}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)}
|
||||
{setupMode === 'direct' && (
|
||||
<DirectSettings busy={busy} onEnable={enableDirect} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProxySettings
|
||||
state={state}
|
||||
settings={clientSettings}
|
||||
busy={busy}
|
||||
onSave={onSaveClientSettings}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/web/components/ConfigViewer.jsx
Normal file
85
src/web/components/ConfigViewer.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function ConfigViewer({ open, onClose }) {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setConfig(null);
|
||||
setError('');
|
||||
api.config()
|
||||
.then((data) => { if (!cancelled) setConfig(data.config); })
|
||||
.catch((err) => { if (!cancelled) setError(err.message); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open]);
|
||||
|
||||
const text = useMemo(() => (config ? JSON.stringify(config, null, 2) : ''), [config]);
|
||||
|
||||
const highlighted = useMemo(() => {
|
||||
if (!search || !text) return text;
|
||||
try {
|
||||
const re = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
return text.split(re);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}, [text, search]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function copy() { navigator.clipboard?.writeText(text).catch(() => {}); }
|
||||
function download() {
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sing-box-config.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3>sing-box config</h3>
|
||||
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<button className="btn btn-ghost sm" disabled={!config} onClick={copy}>Копировать</button>
|
||||
<button className="btn btn-ghost sm" disabled={!config} onClick={download}>Скачать</button>
|
||||
<button className="btn btn-secondary sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{error && <div className="conflict-banner danger">{error}</div>}
|
||||
{!error && !config && <p className="muted">Конфиг ещё не сгенерирован.</p>}
|
||||
{config && (
|
||||
<pre className="config-view">
|
||||
{Array.isArray(highlighted)
|
||||
? highlighted.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{part}
|
||||
{i < highlighted.length - 1 && <mark style={{ background: 'var(--warning-dim)', color: 'var(--warning)' }}>{search}</mark>}
|
||||
</React.Fragment>
|
||||
))
|
||||
: text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
src/web/components/LogsPage.jsx
Normal file
337
src/web/components/LogsPage.jsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { formatTime } from '../utils/format.js';
|
||||
|
||||
const MAX_ENTRIES = 800;
|
||||
const MAX_TRAFFIC = 500;
|
||||
const GROUP_WINDOW_MS = 30_000;
|
||||
|
||||
function normalizeLine(line) {
|
||||
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
|
||||
}
|
||||
|
||||
function groupEntries(entries) {
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
const key = `${e.level}|${normalizeLine(e.line)}`;
|
||||
const last = out[out.length - 1];
|
||||
const ts = new Date(e.ts).getTime();
|
||||
if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) {
|
||||
last.count += 1;
|
||||
last._lastTs = ts;
|
||||
last.lastTs = e.ts;
|
||||
} else {
|
||||
out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE = {
|
||||
direct: { cls: 'success', label: 'direct' },
|
||||
vpn: { cls: 'info', label: 'VPN' },
|
||||
block: { cls: 'danger', label: 'block' },
|
||||
other: { cls: '', label: 'other' },
|
||||
};
|
||||
|
||||
function getDeviceName(sourceIp, devices) {
|
||||
if (!sourceIp || !devices?.length) return null;
|
||||
for (const d of devices) {
|
||||
if (d.enabled === false) continue;
|
||||
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
|
||||
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
|
||||
if (plain === sourceIp) return d.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupTraffic(list, sortBy = 'time') {
|
||||
const map = new Map();
|
||||
for (const e of list) {
|
||||
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
||||
const ts = new Date(e.ts).getTime();
|
||||
if (map.has(key)) {
|
||||
const g = map.get(key);
|
||||
g.count++;
|
||||
g._lastTs = ts;
|
||||
g.lastTs = e.ts;
|
||||
} else {
|
||||
map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
|
||||
}
|
||||
}
|
||||
const arr = Array.from(map.values());
|
||||
if (sortBy === 'count') return arr.sort((a, b) => b.count - a.count || b._lastTs - a._lastTs);
|
||||
return arr.sort((a, b) => b._lastTs - a._lastTs);
|
||||
}
|
||||
|
||||
function TrafficTab({ devices = [] }) {
|
||||
const [traffic, setTraffic] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
||||
const [search, setSearch] = useState('');
|
||||
const [grouped, setGrouped] = useState(true);
|
||||
const [sortBy, setSortBy] = useState('count'); // time | count
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const containerRef = useRef(null);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/traffic/stream');
|
||||
source.onmessage = (ev) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const entry = JSON.parse(ev.data);
|
||||
setTraffic((prev) => {
|
||||
const next = [...prev, entry];
|
||||
if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
return () => source.close();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = traffic;
|
||||
if (filter !== 'all') list = list.filter((e) => e.category === filter);
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
list = list.filter((e) =>
|
||||
e.host?.toLowerCase().includes(s) ||
|
||||
String(e.port || '').includes(s) ||
|
||||
e.outbound?.toLowerCase().includes(s) ||
|
||||
e.matchedRule?.toLowerCase().includes(s) ||
|
||||
e.sourceIp?.toLowerCase().includes(s) ||
|
||||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
|
||||
);
|
||||
}
|
||||
return grouped ? groupTraffic(list, sortBy) : list;
|
||||
}, [traffic, filter, search, grouped, sortBy, devices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll || !containerRef.current) return;
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}, [filtered, autoscroll]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { direct: 0, vpn: 0, block: 0 };
|
||||
for (const e of traffic) if (e.category in c) c[e.category]++;
|
||||
return c;
|
||||
}, [traffic]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||
<div className="filter-bar" style={{ marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск: host, порт, правило…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все ({traffic.length})</option>
|
||||
<option value="direct">direct ({counts.direct})</option>
|
||||
<option value="vpn">VPN ({counts.vpn})</option>
|
||||
<option value="block">block ({counts.block})</option>
|
||||
</select>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
|
||||
Группировать
|
||||
</label>
|
||||
{grouped && (
|
||||
<select className="select" value={sortBy} onChange={(e) => setSortBy(e.target.value)} style={{ width: 'auto' }}>
|
||||
<option value="count">По частоте</option>
|
||||
<option value="time">По времени</option>
|
||||
</select>
|
||||
)}
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} />
|
||||
Автоскролл
|
||||
</label>
|
||||
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>
|
||||
{paused ? '▶ Продолжить' : '⏸ Пауза'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => { setTraffic([]); fetch('/api/traffic', { method: 'DELETE' }).catch(() => {}); }}
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{traffic.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>
|
||||
Ожидаем трафик… Убедитесь что sing-box запущен и уровень логов не выше INFO.
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table className="table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 70 }}>Время</th>
|
||||
<th style={{ width: 70 }}>Туннель</th>
|
||||
<th style={{ width: 110 }}>Устройство</th>
|
||||
<th>Хост / IP</th>
|
||||
<th style={{ width: 55 }}>Порт</th>
|
||||
<th>Правило</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((e, i) => {
|
||||
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
||||
const deviceName = getDeviceName(e.sourceIp, devices);
|
||||
return (
|
||||
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
|
||||
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
|
||||
<td>
|
||||
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
|
||||
</td>
|
||||
<td className="text-mono" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
|
||||
{deviceName
|
||||
? <span style={{ fontSize: 11 }}>{deviceName}</span>
|
||||
: e.sourceIp
|
||||
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
|
||||
: <span className="muted" style={{ fontSize: 11 }}>—</span>}
|
||||
</td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
|
||||
<td className="muted text-mono">{e.port || '—'}</td>
|
||||
<td>
|
||||
{e.matchedRule
|
||||
? <span className="badge info" style={{ fontSize: 11 }}>{e.matchedRule}</span>
|
||||
: <span className="muted" style={{ fontSize: 11 }}>—</span>}
|
||||
</td>
|
||||
<td className="muted text-mono" style={{ textAlign: 'right', fontSize: 11 }}>
|
||||
{e.count > 1 && <span className="repeat">×{e.count}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage({ devices = [] }) {
|
||||
const [tab, setTab] = useState('traffic'); // traffic | logs
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const [grouped, setGrouped] = useState(true);
|
||||
const containerRef = useRef(null);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/logs/stream');
|
||||
source.onmessage = (event) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const entry = JSON.parse(event.data);
|
||||
setEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
if (next.length > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
return () => source.close();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = entries;
|
||||
if (filter !== 'all') list = list.filter((e) => e.level === filter);
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s));
|
||||
}
|
||||
return grouped ? groupEntries(list) : list;
|
||||
}, [entries, filter, search, grouped]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll || !containerRef.current) return;
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}, [filtered, autoscroll]);
|
||||
|
||||
function copy(text) {
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
|
||||
<div className="card-header">
|
||||
<h2>Логи sing-box</h2>
|
||||
<div className="tabs" style={{ marginLeft: 'auto', marginBottom: 0 }}>
|
||||
<button className={`tab ${tab === 'traffic' ? 'active' : ''}`} onClick={() => setTab('traffic')}>Трафик</button>
|
||||
<button className={`tab ${tab === 'logs' ? 'active' : ''}`} onClick={() => setTab('logs')}>Системные логи</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'traffic' && <TrafficTab devices={devices} />}
|
||||
|
||||
{tab === 'logs' && (
|
||||
<>
|
||||
<div className="filter-bar" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск по тексту…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все уровни</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
<label className="checkbox"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} /> Группировать</label>
|
||||
<label className="checkbox"><input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} /> Автоскролл</label>
|
||||
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>{paused ? '▶ Продолжить' : '⏸ Пауза'}</button>
|
||||
<button className="btn btn-ghost sm" onClick={() => setEntries([])}>Очистить</button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="logs-stream">
|
||||
{filtered.length === 0 && <p className="muted">Логов пока нет.</p>}
|
||||
{filtered.map((entry, index) => {
|
||||
const text = normalizeLine(entry.line);
|
||||
if (grouped && entry.count > 1) {
|
||||
return (
|
||||
<div key={`${entry.ts}-${index}`} className="log-group">
|
||||
<span className="log-time mono">{formatTime(entry.ts)}</span>
|
||||
<span className={`log-level text-${entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'info'}`}>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span className="log-text">{text}</span>
|
||||
<span className="repeat">×{entry.count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${entry.ts}-${index}`}
|
||||
className={`log-line ${entry.level}`}
|
||||
onDoubleClick={() => copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
|
||||
title="Двойной клик — скопировать"
|
||||
>
|
||||
<span className="log-time">{formatTime(entry.ts)}</span>
|
||||
<span className="log-level">{entry.level}</span>
|
||||
<span className="log-text">{text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
src/web/components/OverviewPage.jsx
Normal file
192
src/web/components/OverviewPage.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { formatRelative, formatBytes } from '../utils/format.js';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
function StatusHero({ state, status }) {
|
||||
const text = {
|
||||
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
|
||||
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
|
||||
error: { title: '🔴 Ошибка', kind: 'danger' },
|
||||
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
|
||||
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
|
||||
}[status];
|
||||
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
|
||||
: 'нет данных';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
|
||||
<small className="muted">
|
||||
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
|
||||
</small>
|
||||
</div>
|
||||
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="grid-3">
|
||||
<div>
|
||||
<small className="muted">Активный сервер</small>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{state?.selectedTag ? (
|
||||
<>
|
||||
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
|
||||
</>
|
||||
) : <span className="muted">Не выбран</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Трафик</small>
|
||||
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Правил маршрутизации</small>
|
||||
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3>Быстрые действия</h3>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
|
||||
⋆ Сменить сервер
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
|
||||
↻ Перезапустить
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
|
||||
■ Остановить
|
||||
</button>
|
||||
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
|
||||
⌘ Показать config
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
|
||||
disabled={busy || !state?.singboxRunning}
|
||||
onClick={onBypassToggle}
|
||||
title="Весь трафик напрямую — для диагностики"
|
||||
>
|
||||
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentEvents({ onNav }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/logs')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const list = (data.logs || []).slice(-15).reverse();
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3>Последние события</h3>
|
||||
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи →</button>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<small className="muted">Пока ничего нет.</small>
|
||||
) : (
|
||||
<div className="events-list">
|
||||
{entries.slice(0, 8).map((e, i) => {
|
||||
const dot = e.level === 'error' ? 'danger'
|
||||
: e.level === 'warning' ? 'warning'
|
||||
: 'success';
|
||||
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
|
||||
return (
|
||||
<div key={`${e.ts}-${i}`} className="event-row">
|
||||
<span className={`dot ${dot}`} />
|
||||
<span className="event-time">{time}</span>
|
||||
<span className="text-truncate" title={e.line}>{e.line}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
|
||||
const rules = state?.customRules || [];
|
||||
const enabled = rules.filter((r) => r.enabled).length;
|
||||
const cacheCount = state?.directBypassCount || 0;
|
||||
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
|
||||
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'vpn';
|
||||
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3>Маршрутизация</h3>
|
||||
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила →</button>
|
||||
</div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">Private IP</span><span className="val text-success">→ direct</span></div>
|
||||
{state?.routingRuDirect && (
|
||||
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success">→ direct</span></div>
|
||||
)}
|
||||
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||||
<div className="row"><span className="key">Transparent fallback</span><span className="val">→ {transparentDefault}</span></div>
|
||||
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning">→ {proxyDefault}</span></div>
|
||||
{cacheAvailable && (
|
||||
<div className="row">
|
||||
<span className="key">Direct bypass cache</span>
|
||||
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-success">{cacheCount} IP</span>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
|
||||
✕ сбросить
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
{state?.bypassMode && (
|
||||
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<strong>⚠ Режим обхода правил активен</strong>
|
||||
<span className="muted">— весь трафик идёт напрямую, VPN-правила не применяются.</span>
|
||||
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
|
||||
Отключить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<StatusHero state={state} status={status} />
|
||||
<div className="grid-2">
|
||||
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
|
||||
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
|
||||
</div>
|
||||
<RecentEvents onNav={onNav} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/web/components/RouteChecker.jsx
Normal file
93
src/web/components/RouteChecker.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function RouteChecker() {
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('443');
|
||||
const [network, setNetwork] = useState('tcp');
|
||||
const [sourceIp, setSourceIp] = useState('');
|
||||
const [inbound, setInbound] = useState('tproxy-in');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function check() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await api.route.check({
|
||||
host,
|
||||
port: port || undefined,
|
||||
network,
|
||||
sourceIp: sourceIp || undefined,
|
||||
inbound,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const r = result?.result;
|
||||
const kind = r?.outbound?.startsWith('direct') ? 'success'
|
||||
: r?.outbound === 'block' ? 'danger'
|
||||
: r?.outbound?.includes('VPN') || r?.outbound?.includes('vpn') ? 'info'
|
||||
: 'warning';
|
||||
|
||||
return (
|
||||
<div className="card flat compact">
|
||||
<div className="card-header no-margin"><h3>Проверить маршрут</h3></div>
|
||||
<div className="filter-bar" style={{ marginTop: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="домен или IP (riotgames.com)"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && check()}
|
||||
style={{ minWidth: 220, flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="port"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select className="select" value={network} onChange={(e) => setNetwork(e.target.value)} style={{ width: 90 }}>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="source IP"
|
||||
value={sourceIp}
|
||||
onChange={(e) => setSourceIp(e.target.value)}
|
||||
style={{ width: 145 }}
|
||||
/>
|
||||
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
|
||||
<option value="tproxy-in">tproxy-in</option>
|
||||
<option value="mixed-in">mixed-in</option>
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="field-error" style={{ marginTop: 10 }}>{error}</div>}
|
||||
|
||||
{r && (
|
||||
<div className="route-result" style={{ marginTop: 12 }}>
|
||||
<div className="flex-between">
|
||||
<strong>{r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName}</strong>
|
||||
<span className={`badge ${kind}`}>→ {r.outbound}</span>
|
||||
</div>
|
||||
{result.resolvedIp && result.resolvedFrom && (
|
||||
<small className="muted text-mono">DNS: {result.resolvedFrom} → {result.resolvedIp}</small>
|
||||
)}
|
||||
<small className="muted">{r.reason}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
382
src/web/components/RoutingPage.jsx
Normal file
382
src/web/components/RoutingPage.jsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ruleTemplates } from '../templates/ruleTemplates.js';
|
||||
import { ruleErrors, hasErrors } from '../utils/validation.js';
|
||||
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
|
||||
import { RouteChecker } from './RouteChecker.jsx';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const OUTBOUND_KIND = {
|
||||
direct: { kind: 'success', label: 'direct' },
|
||||
vpn: { kind: 'info', label: 'VPN' },
|
||||
block: { kind: 'danger', label: 'block' },
|
||||
};
|
||||
|
||||
const DEVICE_MODES = {
|
||||
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
||||
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
||||
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
||||
block: { kind: 'danger', label: 'block', hint: 'fallback после global rules' },
|
||||
};
|
||||
|
||||
function DeviceModeSelect({ value, onChange }) {
|
||||
return (
|
||||
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="rules">default</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
|
||||
const devices = devicesConfig?.devices || [];
|
||||
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'vpn';
|
||||
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h2>Устройства</h2>
|
||||
<small className="muted">Global rules применяются первыми. Эти значения — fallback после них.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
||||
<span className="field-label">Transparent default</span>
|
||||
<select
|
||||
className="select sm"
|
||||
value={defaultTransparentMode}
|
||||
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
|
||||
>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ minWidth: 160, margin: 0 }}>
|
||||
<span className="field-label">Proxy default</span>
|
||||
<select
|
||||
className="select sm"
|
||||
value={proxyDefaultMode}
|
||||
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
|
||||
>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="direct">direct</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn btn-primary sm" onClick={onAdd}>
|
||||
+ Добавить устройство
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}>
|
||||
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Название</th>
|
||||
<th style={{ width: 170 }}>IP</th>
|
||||
<th style={{ width: 150 }}>MAC</th>
|
||||
<th style={{ width: 150 }}>Mode</th>
|
||||
<th>Поведение</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((dev) => {
|
||||
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
|
||||
return (
|
||||
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dev.enabled !== false}
|
||||
onChange={(e) => onUpdate(dev.id, { enabled: e.target.checked })}
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.name || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { name: e.target.value })}
|
||||
placeholder="Название устройства"
|
||||
style={{ width: '100%', minWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.ip || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
|
||||
placeholder="192.168.1.50"
|
||||
style={{ width: '100%', minWidth: 140 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.mac || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
|
||||
placeholder="опционально"
|
||||
style={{ width: '100%', minWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${mode.kind}`}>{mode.label}</span>
|
||||
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить устройство?')) onRemove(dev.id);
|
||||
}}
|
||||
>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function summary(rule) {
|
||||
const parts = [];
|
||||
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
|
||||
if (totalDomains) parts.push(`${totalDomains} дом.`);
|
||||
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
|
||||
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
|
||||
if (rule.networks?.length) parts.push(rule.networks.join('/'));
|
||||
return parts.join(' · ') || '—';
|
||||
}
|
||||
|
||||
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
|
||||
const errors = ruleErrors(rule);
|
||||
const invalid = hasErrors(errors);
|
||||
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
|
||||
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
|
||||
<td style={{ width: 30 }}>
|
||||
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить">⠿</span>
|
||||
</td>
|
||||
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
|
||||
<td>
|
||||
<div className="flex" style={{ alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
|
||||
{rule.name || '(без названия)'}
|
||||
</button>
|
||||
{invalid && <span className="badge danger">ошибки</span>}
|
||||
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
|
||||
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
|
||||
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatesModal({ open, onClose, onAdd }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h3>Шаблоны маршрутизации</h3>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="template-grid">
|
||||
{ruleTemplates.map((tpl) => (
|
||||
<div key={tpl.key} className="template-card">
|
||||
<h4>{tpl.label}</h4>
|
||||
<small>{tpl.description}</small>
|
||||
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutingPage({
|
||||
rules, saveStatus, busy,
|
||||
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
|
||||
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [availableRuleSets, setAvailableRuleSets] = useState([]);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const t = setTimeout(() => {
|
||||
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
|
||||
}, 600);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [rules]);
|
||||
|
||||
const conflictsByRuleId = useMemo(() => {
|
||||
const map = {};
|
||||
for (const c of conflicts) map[c.ruleId] = c;
|
||||
return map;
|
||||
}, [conflicts]);
|
||||
|
||||
function handleDragEnd(event) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = rules.findIndex((r) => r.id === active.id);
|
||||
const newIndex = rules.findIndex((r) => r.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
onReorder(arrayMove(rules, oldIndex, newIndex));
|
||||
}
|
||||
|
||||
const editing = rules.find((r) => r.id === editingId) || null;
|
||||
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<RouteChecker />
|
||||
|
||||
<DevicesCard
|
||||
devicesConfig={devicesConfig}
|
||||
onDefaultsChange={onUpdateDeviceDefaults}
|
||||
onAdd={onAddDevice}
|
||||
onUpdate={onUpdateDevice}
|
||||
onRemove={onRemoveDevice}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Правила маршрутизации</h2>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
|
||||
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<div className="conflict-banner" style={{ marginBottom: 12 }}>
|
||||
<span>⚠</span>
|
||||
<div>
|
||||
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{conflicts.slice(0, 3).map((c, i) => (
|
||||
<div key={i} style={{ fontSize: 12 }}>
|
||||
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Применяются <strong>сверху вниз</strong>. Перетаскивай ⠿ чтобы менять порядок.
|
||||
</small>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>Правил пока нет</h3>
|
||||
<p>Добавь шаблон (например «League of Legends → direct») или создай пустое правило.</p>
|
||||
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
|
||||
Открыть шаблоны
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>#</th>
|
||||
<th>Правило</th>
|
||||
<th>Outbound</th>
|
||||
<th>Условия</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||||
{rules.map((rule, i) => (
|
||||
<SortableRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
index={i}
|
||||
total={rules.length}
|
||||
onEdit={setEditingId}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={onRemove}
|
||||
conflict={conflictsByRuleId[rule.id]}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RuleEditorDrawer
|
||||
rule={editing}
|
||||
onUpdate={onUpdate}
|
||||
onClose={() => setEditingId(null)}
|
||||
onRemove={onRemove}
|
||||
availableRuleSets={availableRuleSets}
|
||||
/>
|
||||
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
453
src/web/components/RuleEditorDrawer.jsx
Normal file
453
src/web/components/RuleEditorDrawer.jsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChipsInput } from './ChipsInput.jsx';
|
||||
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
const RULE_SET_TAG = /^[a-z0-9][a-z0-9_.@!-]*$/i;
|
||||
const validDomain = (v) => DOMAIN.test(String(v).trim());
|
||||
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
|
||||
|
||||
const RS_PAGE_SIZE = 100;
|
||||
const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' };
|
||||
|
||||
function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) {
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.lookup(tag, url)
|
||||
.then((d) => { setData(d); setStatus('done'); })
|
||||
.catch((err) => { setError(err.message); setStatus('error'); });
|
||||
}, [tag, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, [status]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data?.entries) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return data.entries.filter((e) => {
|
||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
||||
if (!q) return true;
|
||||
return e.value.toLowerCase().includes(q);
|
||||
});
|
||||
}, [data, search, typeFilter]);
|
||||
|
||||
function onSearchChange(v) { setSearch(v); setPage(0); }
|
||||
function onTypeChange(v) { setTypeFilter(v); setPage(0); }
|
||||
|
||||
function addEntry(entry) {
|
||||
const val = entry.value;
|
||||
switch (entry.type) {
|
||||
case 'domain': {
|
||||
const cur = new Set(rule.domains || []);
|
||||
if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'suffix': {
|
||||
const cur = new Set(rule.domainSuffixes || []);
|
||||
if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'keyword': {
|
||||
const cur = new Set(rule.domainKeywords || []);
|
||||
if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'cidr': {
|
||||
const cur = new Set(rule.ipCidrs || []);
|
||||
if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] });
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE);
|
||||
const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE);
|
||||
|
||||
const addedValues = useMemo(() => new Set([
|
||||
...(rule.domains || []),
|
||||
...(rule.domainSuffixes || []),
|
||||
...(rule.domainKeywords || []),
|
||||
...(rule.ipCidrs || []),
|
||||
]), [rule]);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" style={{ zIndex: 1100 }} onClick={onClose}>
|
||||
<div
|
||||
className="modal lg"
|
||||
style={{ maxWidth: 680, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
|
||||
<small className="muted">Кликните запись чтобы добавить в правило</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Скачивание и декомпиляция…<br />
|
||||
<small>Может занять 10–30 секунд</small>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="conflict-banner danger"><span>✗</span><div>{error}</div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'done' && data && (
|
||||
<>
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
|
||||
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
|
||||
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
|
||||
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
|
||||
</div>
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Поиск: youtube, 149.154, .ru…"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<select className="select" style={{ width: 130 }} value={typeFilter} onChange={(e) => onTypeChange(e.target.value)}>
|
||||
<option value="all">Все типы</option>
|
||||
{Object.entries(RS_TYPE_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '6px 0' }}>
|
||||
{filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
|
||||
{totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
|
||||
<span className="muted" style={{ marginLeft: 12 }}>— нажмите строку чтобы добавить в правило</span>
|
||||
</div>
|
||||
<table className="table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: 70 }}>Тип</th><th>Значение</th><th style={{ width: 30 }}></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((e, i) => {
|
||||
const already = addedValues.has(e.value);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
style={{ cursor: already ? 'default' : 'pointer', opacity: already ? 0.5 : 1 }}
|
||||
onClick={() => !already && addEntry(e)}
|
||||
title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
|
||||
>
|
||||
<td><span className="badge" style={{ fontSize: 10 }}>{RS_TYPE_LABELS[e.type] || e.type}</span></td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 14 }}>{already ? '✓' : '+'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex" style={{ gap: 8, padding: '10px 0', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>‹</button>
|
||||
<span className="muted" style={{ lineHeight: '28px', fontSize: 12 }}>{page + 1} / {totalPages}</span>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}>›</button>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
|
||||
const [view, setView] = useState(mode); // builder | json
|
||||
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null
|
||||
const errors = ruleErrors(rule);
|
||||
|
||||
// Индекс URL по тегу из доступных rule-sets
|
||||
const ruleSetUrlMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rs of availableRuleSets) map[rs.tag] = rs.url;
|
||||
return map;
|
||||
}, [availableRuleSets]);
|
||||
|
||||
function patch(p) {
|
||||
onUpdate(rule.id, p);
|
||||
}
|
||||
|
||||
function applyJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonDraft);
|
||||
onUpdate(rule.id, { ...parsed, id: rule.id });
|
||||
setJsonError('');
|
||||
} catch (err) {
|
||||
setJsonError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer-body">
|
||||
<div className="tabs">
|
||||
<button className={`tab ${view === 'builder' ? 'active' : ''}`} onClick={() => setView('builder')}>Конструктор</button>
|
||||
<button className={`tab ${view === 'json' ? 'active' : ''}`} onClick={() => { setJsonDraft(JSON.stringify(rule, null, 2)); setView('json'); }}>Raw JSON</button>
|
||||
</div>
|
||||
|
||||
{view === 'builder' ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="field-label">Название</span>
|
||||
<input className="input" value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<span className="field-label">Outbound</span>
|
||||
<select className="select" value={rule.outbound} onChange={(e) => patch({ outbound: e.target.value })}>
|
||||
<option value="direct">direct (напрямую)</option>
|
||||
<option value="vpn">vpn (через выбранный сервер)</option>
|
||||
<option value="block">block (заблокировать)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="field-label">Состояние</span>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(e) => patch({ enabled: e.target.checked })}
|
||||
/>
|
||||
Правило включено
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Rule-sets (geo-базы)</span>
|
||||
<ChipsInput
|
||||
value={rule.ruleSets || []}
|
||||
onChange={(v) => patch({ ruleSets: v })}
|
||||
placeholder="geosite-runet"
|
||||
validate={validRuleSetTag}
|
||||
/>
|
||||
{/* Кнопки просмотра содержимого для выбранных rule-sets */}
|
||||
{(rule.ruleSets || []).length > 0 && (
|
||||
<div className="field-hint" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{(rule.ruleSets || []).map((tag) => {
|
||||
const url = ruleSetUrlMap[tag];
|
||||
return url ? (
|
||||
<button
|
||||
key={tag}
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', fontSize: 11 }}
|
||||
onClick={() => setBrowseTag({ tag, url })}
|
||||
title={`Просмотреть содержимое ${tag}`}
|
||||
>
|
||||
🔍 {tag}
|
||||
</button>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{availableRuleSets.length > 0 && (
|
||||
<div className="field-hint">
|
||||
Доступны:{' '}
|
||||
{availableRuleSets.map((rs) => (
|
||||
<span key={rs.tag} style={{ display: 'inline-flex', alignItems: 'center', marginRight: 4 }}>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', marginRight: 2 }}
|
||||
onClick={() => {
|
||||
const current = new Set(rule.ruleSets || []);
|
||||
if (!current.has(rs.tag)) {
|
||||
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {rs.tag}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 4px', fontSize: 12 }}
|
||||
onClick={() => setBrowseTag({ tag: rs.tag, url: rs.url })}
|
||||
title="Просмотреть содержимое"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{availableRuleSets.length === 0 && (
|
||||
<span className="field-hint">
|
||||
Настройте rule-sets в Настройках, затем вводите их теги здесь
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Домены (точное совпадение)</span>
|
||||
<ChipsInput
|
||||
value={rule.domains || []}
|
||||
onChange={(v) => patch({ domains: v })}
|
||||
placeholder="riotgames.com"
|
||||
validate={validDomain}
|
||||
/>
|
||||
{errors.domains.length > 0 && <span className="field-error">Невалидно: {errors.domains.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Суффиксы доменов</span>
|
||||
<ChipsInput
|
||||
value={rule.domainSuffixes || []}
|
||||
onChange={(v) => patch({ domainSuffixes: v })}
|
||||
placeholder="riotcdn.net"
|
||||
validate={validDomain}
|
||||
/>
|
||||
{errors.domainSuffixes.length > 0 && <span className="field-error">Невалидно: {errors.domainSuffixes.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">IP / CIDR</span>
|
||||
<ChipsInput
|
||||
value={rule.ipCidrs || []}
|
||||
onChange={(v) => patch({ ipCidrs: v })}
|
||||
placeholder="104.160.128.0/19"
|
||||
validate={isValidCidr}
|
||||
/>
|
||||
{errors.ipCidrs.length > 0 && <span className="field-error">Невалидно: {errors.ipCidrs.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Порты (число или диапазон 5000-6000)</span>
|
||||
<ChipsInput
|
||||
value={rule.ports || []}
|
||||
onChange={(v) => patch({ ports: v })}
|
||||
placeholder="443"
|
||||
validate={(p) => {
|
||||
const s = String(p);
|
||||
if (s.includes('-')) {
|
||||
const [a, b] = s.split('-');
|
||||
return isValidPort(a) && isValidPort(b);
|
||||
}
|
||||
return isValidPort(p);
|
||||
}}
|
||||
/>
|
||||
{errors.ports.length > 0 && <span className="field-error">Невалидно: {errors.ports.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Протоколы</span>
|
||||
<div className="flex">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(rule.networks || []).includes('tcp')}
|
||||
onChange={(e) => {
|
||||
const set = new Set(rule.networks || []);
|
||||
e.target.checked ? set.add('tcp') : set.delete('tcp');
|
||||
patch({ networks: Array.from(set) });
|
||||
}}
|
||||
/>
|
||||
TCP
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(rule.networks || []).includes('udp')}
|
||||
onChange={(e) => {
|
||||
const set = new Set(rule.networks || []);
|
||||
e.target.checked ? set.add('udp') : set.delete('udp');
|
||||
patch({ networks: Array.from(set) });
|
||||
}}
|
||||
/>
|
||||
UDP
|
||||
</label>
|
||||
<span className="field-hint">Если ничего — оба</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="field-label">Сырой JSON правила</span>
|
||||
<textarea
|
||||
className="textarea"
|
||||
style={{ minHeight: 320 }}
|
||||
value={jsonDraft}
|
||||
onChange={(e) => setJsonDraft(e.target.value)}
|
||||
/>
|
||||
{jsonError && <span className="field-error">{jsonError}</span>}
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-primary" onClick={applyJson}>Применить JSON</button>
|
||||
<button className="btn btn-ghost" onClick={() => setJsonDraft(JSON.stringify(rule, null, 2))}>Сбросить</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{browseTag && (
|
||||
<RuleSetBrowseModal
|
||||
tag={browseTag.tag}
|
||||
url={browseTag.url}
|
||||
rule={rule}
|
||||
onPatch={(p) => patch(p)}
|
||||
onClose={() => setBrowseTag(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
|
||||
if (!rule) return null;
|
||||
const errors = ruleErrors(rule);
|
||||
const invalid = hasErrors(errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop" onClick={onClose} />
|
||||
<aside className="drawer">
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<h3>Редактирование правила</h3>
|
||||
<small className="muted">{rule.name || '(без названия)'}</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
|
||||
<div className="drawer-foot">
|
||||
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
|
||||
<div className="btn-group">
|
||||
{invalid && <span className="badge danger">Есть ошибки</span>}
|
||||
<button className="btn btn-primary" onClick={onClose}>Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
210
src/web/components/ServersPage.jsx
Normal file
210
src/web/components/ServersPage.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
|
||||
function PingCell({ ping }) {
|
||||
if (!ping) return <span className="muted">—</span>;
|
||||
if (ping.checking) return <span className="badge neutral pulse">проверяем…</span>;
|
||||
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
|
||||
const ms = ping.latency;
|
||||
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
|
||||
return <span className={`badge ${kind}`}>{ms} ms</span>;
|
||||
}
|
||||
|
||||
function StatusCell({ ping }) {
|
||||
if (!ping) return <span className="badge neutral">unknown</span>;
|
||||
if (ping.checking) return <span className="badge neutral pulse">…</span>;
|
||||
return ping.ok
|
||||
? <span className="badge success">● online</span>
|
||||
: <span className="badge danger">● offline</span>;
|
||||
}
|
||||
|
||||
export function ServersPage({
|
||||
state,
|
||||
servers,
|
||||
selectedTag,
|
||||
setSelectedTag,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onApply,
|
||||
onRollback,
|
||||
pings,
|
||||
setPings,
|
||||
pushToast,
|
||||
}) {
|
||||
const [filter, setFilter] = useState('all'); // all | online
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
async function pingOne(server) {
|
||||
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
|
||||
try {
|
||||
const res = await api.servers.ping(server.server, server.server_port);
|
||||
setPings((prev) => ({
|
||||
...prev,
|
||||
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
|
||||
}));
|
||||
} catch (err) {
|
||||
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
|
||||
}
|
||||
}
|
||||
|
||||
async function pingAll() {
|
||||
setPings((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const s of servers) next[s.tag] = { checking: true };
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const res = await api.servers.pingAll();
|
||||
const map = {};
|
||||
for (const r of res.results || []) {
|
||||
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
|
||||
}
|
||||
setPings((prev) => ({ ...prev, ...map }));
|
||||
pushToast({ kind: 'success', title: 'Пинг завершён' });
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return servers.filter((s) => {
|
||||
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (filter === 'online' && !pings[s.tag]?.ok) return false;
|
||||
return true;
|
||||
});
|
||||
}, [servers, search, filter, pings]);
|
||||
|
||||
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
|
||||
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
|
||||
const pendingServer = servers.find((s) => s.tag === pendingTag);
|
||||
|
||||
if (!servers.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<h3>Серверы ещё не загружены</h3>
|
||||
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section-stack">
|
||||
{pendingDifferent && (
|
||||
<div className="card" style={{ borderColor: 'var(--warning)' }}>
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
|
||||
Отменить
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
|
||||
Применить изменения
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Серверы ({servers.length})</h2>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
|
||||
⚡ Проверить все
|
||||
</button>
|
||||
{state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
|
||||
↶ Откатить ({state.previousTag})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск по тегу или хосту…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все</option>
|
||||
<option value="online">Только online</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 16 }}></th>
|
||||
<th>Сервер</th>
|
||||
<th>Хост</th>
|
||||
<th>Тип</th>
|
||||
<th>Ping</th>
|
||||
<th>Статус</th>
|
||||
<th style={{ textAlign: 'right' }}>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((server) => {
|
||||
const isActive = server.tag === state?.selectedTag;
|
||||
const isPending = server.tag === pendingTag && !isActive;
|
||||
const ping = pings[server.tag];
|
||||
return (
|
||||
<tr key={server.tag} className={isActive ? 'active' : ''}>
|
||||
<td>{flagFor(server)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong>{server.tag}</strong>
|
||||
{isActive && <span className="badge success">ACTIVE</span>}
|
||||
{isPending && <span className="badge warning">pending</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-mono muted">{server.server}:{server.server_port}</td>
|
||||
<td><span className="badge neutral">{server.type}</span></td>
|
||||
<td><PingCell ping={ping} /></td>
|
||||
<td><StatusCell ping={ping} /></td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
|
||||
Ping
|
||||
</button>
|
||||
{isActive ? (
|
||||
<button className="btn btn-secondary sm" disabled>Активен</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary sm"
|
||||
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
|
||||
disabled={busy}
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!filtered.length && (
|
||||
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
784
src/web/components/SettingsPage.jsx
Normal file
784
src/web/components/SettingsPage.jsx
Normal file
@@ -0,0 +1,784 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
|
||||
const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' };
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function RuleSetLookupModal({ tag, url, onClose }) {
|
||||
const [state, setState] = useState('idle'); // idle | loading | done | error
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState(null); // { entries, stats, cachedAt }
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading');
|
||||
api.ruleSets.lookup(tag, url)
|
||||
.then((res) => { setData(res); setState('done'); })
|
||||
.catch((err) => { setError(err.message); setState('error'); });
|
||||
}, [tag, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, [state]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data?.entries) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return data.entries.filter((e) => {
|
||||
if (filterType !== 'all' && e.type !== filterType) return false;
|
||||
if (!q) return true;
|
||||
return e.value.toLowerCase().includes(q);
|
||||
});
|
||||
}, [data, search, filterType]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
function onSearchChange(v) { setSearch(v); setPage(0); }
|
||||
function onTypeChange(v) { setFilterType(v); setPage(0); }
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal lg" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
|
||||
<small className="muted">{url}</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Скачивание и декомпиляция…<br />
|
||||
<small>Может занять 10–30 секунд</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="conflict-banner danger"><span>✗</span><div>{error}</div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'done' && data && (
|
||||
<>
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
|
||||
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
|
||||
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
|
||||
{data.stats.keyword > 0 && <span className="badge">ключ. слов: {data.stats.keyword.toLocaleString()}</span>}
|
||||
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
|
||||
{data.stats.regex > 0 && <span className="badge">regex: {data.stats.regex.toLocaleString()}</span>}
|
||||
<span className="muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
|
||||
кеш: {formatRelative(data.cachedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Поиск: youtube, 149.154, .ru…"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<select className="select" style={{ width: 140 }} value={filterType} onChange={(e) => onTypeChange(e.target.value)}>
|
||||
<option value="all">Все типы</option>
|
||||
{Object.entries(TYPE_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '8px 0' }}>
|
||||
Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
|
||||
{totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`}
|
||||
</div>
|
||||
<table className="table" style={{ fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: 80 }}>Тип</th><th>Значение</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td><span className="badge">{TYPE_LABELS[e.type] || e.type}</span></td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex" style={{ gap: 8, padding: '12px 0', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>‹</button>
|
||||
<span className="muted" style={{ lineHeight: '28px', fontSize: 13 }}>{page + 1} / {totalPages}</span>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}>›</button>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Каталог готовых rule-set источников для sing-box (.srs формат)
|
||||
// Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр)
|
||||
const RULE_SET_CATALOG = [
|
||||
{
|
||||
tag: 'geosite-runet',
|
||||
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
|
||||
source: 'runetfreedom',
|
||||
category: 'RU',
|
||||
description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.',
|
||||
examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'],
|
||||
use: 'vpn — маршрутизировать заблокированные домены через VPN.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geoip-ru',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
|
||||
source: 'SagerNet/sing-geoip',
|
||||
category: 'RU',
|
||||
description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.',
|
||||
examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'],
|
||||
use: 'direct — российские сервисы без VPN.',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-category-ru',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'RU',
|
||||
description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.',
|
||||
examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'],
|
||||
use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-google',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.',
|
||||
examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'],
|
||||
use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-youtube',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.',
|
||||
examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'],
|
||||
use: 'vpn — для разблокировки YouTube.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-telegram',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.',
|
||||
examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'],
|
||||
use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-openai',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.',
|
||||
examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'],
|
||||
use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-apple',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.',
|
||||
examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'],
|
||||
use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-github',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Разработка',
|
||||
description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.',
|
||||
examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'],
|
||||
use: 'vpn — если GitHub замедлен или заблокирован.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-category-ads-all',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Блокировка',
|
||||
description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.',
|
||||
examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'],
|
||||
use: 'block — блокировка рекламы и трекеров на уровне DNS.',
|
||||
builtIn: false,
|
||||
},
|
||||
];
|
||||
|
||||
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
|
||||
const [editing, setEditing] = useState(!state?.hasSubscription);
|
||||
|
||||
useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]);
|
||||
|
||||
const masked = state?.hasSubscription && !editing;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Подписка</h2>
|
||||
{state?.hasSubscription && (
|
||||
<span className="badge success">● активна</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{masked ? (
|
||||
<div className="kv-list">
|
||||
<div className="row">
|
||||
<span className="key">URL</span>
|
||||
<span className="val text-mono">{state.subscriptionHost}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="key">Серверов</span>
|
||||
<span className="val">{state.servers?.length || 0}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="key">Загружено</span>
|
||||
<span className="val">{state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field">
|
||||
<span className="field-label">Subscription URL</span>
|
||||
<div className="subscription-input">
|
||||
<input
|
||||
className="input"
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
placeholder="https://provider.example/sub/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btn-group" style={{ marginTop: 16 }}>
|
||||
{masked ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setEditing(true)} disabled={busy}>Изменить URL</button>
|
||||
<button className="btn btn-secondary" onClick={onFetch} disabled={busy}>↻ Обновить серверы</button>
|
||||
<button className="btn btn-danger" onClick={onForget} disabled={busy}>Удалить подписку</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={async () => { await onFetch(); setEditing(false); }}
|
||||
disabled={busy || !subscriptionUrl}
|
||||
>
|
||||
{busy ? 'Загрузка…' : 'Загрузить серверы'}
|
||||
</button>
|
||||
{state?.hasSubscription && (
|
||||
<button className="btn btn-ghost" onClick={() => setEditing(false)}>Отмена</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
|
||||
const [validation, setValidation] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
async function validate() {
|
||||
setValidating(true);
|
||||
try {
|
||||
const data = await api.configValidate();
|
||||
setValidation(data);
|
||||
pushToast({
|
||||
kind: data.valid ? 'success' : 'danger',
|
||||
title: data.valid ? 'Config валиден' : 'Config невалиден',
|
||||
message: data.error || data.note,
|
||||
});
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message });
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>sing-box config</h2>
|
||||
{validation && (
|
||||
<span className={`badge ${validation.valid ? 'success' : 'danger'}`}>
|
||||
{validation.valid ? '✓ валиден' : '✗ ошибка'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">Файл</span><span className="val">{state?.configExists ? 'есть' : 'нет'}</span></div>
|
||||
<div className="row"><span className="key">Применено</span><span className="val">{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}</span></div>
|
||||
</div>
|
||||
<div className="btn-group" style={{ marginTop: 16 }}>
|
||||
<button className="btn btn-secondary" disabled={!state?.configExists} onClick={onShowConfig}>Показать config</button>
|
||||
<button className="btn btn-secondary" disabled={validating || !state?.configExists} onClick={validate}>
|
||||
{validating ? 'Проверяем…' : '✓ Валидировать'}
|
||||
</button>
|
||||
<button className="btn btn-danger" disabled={busy || !state?.configExists} onClick={onClearConfig}>
|
||||
Сбросить config
|
||||
</button>
|
||||
</div>
|
||||
{validation && !validation.valid && validation.error && (
|
||||
<div className="conflict-banner danger" style={{ marginTop: 12 }}>
|
||||
<span>✗</span><div>{validation.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))];
|
||||
|
||||
function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
|
||||
<div className="flex" style={{ alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="flex" style={{ alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<strong className="text-mono" style={{ fontSize: 13 }}>{entry.tag}</strong>
|
||||
<span className="badge info" style={{ fontSize: 11 }}>{entry.category}</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>{entry.source}</span>
|
||||
{entry.builtIn && (
|
||||
<span className="badge success" style={{ fontSize: 11 }} title="Загружается автоматически при включённом RU direct">встроен</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, marginTop: 2, color: 'var(--text)' }}>{entry.description}</div>
|
||||
</div>
|
||||
<div className="flex" style={{ gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Примеры и подсказка"
|
||||
>
|
||||
{open ? '▲' : '▼'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => onLookup(entry)}
|
||||
title="Просмотреть содержимое и искать внутри"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
disabled={busy || added}
|
||||
onClick={() => onAdd(entry)}
|
||||
>
|
||||
{added ? '✓ добавлен' : '+ Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||||
<span className="muted">Примеры содержимого: </span>
|
||||
{entry.examples.map((ex, i) => (
|
||||
<span key={i}>
|
||||
<code style={{ background: 'var(--bg-muted)', borderRadius: 3, padding: '1px 5px', fontSize: 11 }}>{ex}</code>
|
||||
{i < entry.examples.length - 1 ? ' ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<span className="muted">Рекомендуемый outbound: </span>
|
||||
<span>{entry.use}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState('idle'); // idle | loading | done | error
|
||||
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
|
||||
const [error, setError] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
|
||||
|
||||
function load() {
|
||||
if (status !== 'idle') return;
|
||||
setStatus('loading');
|
||||
api.ruleSets.sagernetCatalog()
|
||||
.then((d) => { setCatalog(d); setStatus('done'); })
|
||||
.catch((err) => { setError(err.message); setStatus('error'); });
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open && status === 'idle') load();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
|
||||
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
|
||||
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
|
||||
const all = [...gs, ...gi];
|
||||
if (!q) return all;
|
||||
return all.filter((item) => item.name.includes(q));
|
||||
}, [catalog, query, repoFilter]);
|
||||
|
||||
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
|
||||
<h2>Поиск в каталоге SagerNet</h2>
|
||||
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
|
||||
{status === 'done' && catalog && (
|
||||
<span className="badge info" style={{ fontSize: 11 }}>
|
||||
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
|
||||
</span>
|
||||
)}
|
||||
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{status === 'loading' && (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Загрузка списка из GitHub…
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
|
||||
<span>✗</span><div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
<>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
|
||||
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
|
||||
Кеш обновляется раз в 24 ч.
|
||||
</small>
|
||||
{catalog.fallback && (
|
||||
<div className="conflict-banner warning" style={{ marginBottom: 12 }}>
|
||||
<span>!</span><div>{catalog.warning || 'Показан встроенный fallback-каталог.'}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
placeholder="steam, gaming, netflix, cloudflare…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
|
||||
<option value="all">geosite + geoip</option>
|
||||
<option value="geosite">только geosite</option>
|
||||
<option value="geoip">только geoip</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{query.trim() === '' ? (
|
||||
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
|
||||
Введите запрос — покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
|
||||
) : (
|
||||
<table className="table" style={{ fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>Тип</th>
|
||||
<th>Тег</th>
|
||||
<th style={{ width: 120 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.slice(0, 100).map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
|
||||
<td className="text-mono">{item.name}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{addedTags.has(item.name) ? (
|
||||
<span className="badge success" style={{ fontSize: 11 }}>✓ добавлен</span>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
disabled={busy}
|
||||
onClick={() => onAdd({ tag: item.name, url: item.url })}
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{results.length > 100 && (
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||||
Показано 100 из {results.length} — уточните запрос
|
||||
</div>
|
||||
)}
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
|
||||
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleSetsCard({ pushToast }) {
|
||||
const [ruleSets, setRuleSets] = useState([]);
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('Все');
|
||||
const [lookup, setLookup] = useState(null); // { tag, url }
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function save(next) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.ruleSets.save(next);
|
||||
setRuleSets(data.ruleSets || []);
|
||||
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addNew() {
|
||||
const tag = newTag.trim();
|
||||
const url = newUrl.trim();
|
||||
if (!tag || !url) return;
|
||||
if (!/^[a-z0-9][a-z0-9_.@!-]*$/i.test(tag)) {
|
||||
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Буквы, цифры и символы - _ . @ !' });
|
||||
return;
|
||||
}
|
||||
if (ruleSets.some((rs) => rs.tag === tag)) {
|
||||
pushToast({ kind: 'danger', title: 'Тег уже существует' });
|
||||
return;
|
||||
}
|
||||
const next = [...ruleSets, { tag, url }];
|
||||
setNewTag('');
|
||||
setNewUrl('');
|
||||
save(next);
|
||||
}
|
||||
|
||||
function remove(tag) {
|
||||
save(ruleSets.filter((rs) => rs.tag !== tag));
|
||||
}
|
||||
|
||||
function addFromCatalog(entry) {
|
||||
if (ruleSets.some((rs) => rs.tag === entry.tag)) {
|
||||
pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` });
|
||||
return;
|
||||
}
|
||||
save([...ruleSets, { tag: entry.tag, url: entry.url }]);
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = RULE_SET_CATALOG.filter((entry) => {
|
||||
if (category !== 'Все' && entry.category !== category) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
entry.tag.includes(q) ||
|
||||
entry.description.toLowerCase().includes(q) ||
|
||||
entry.source.toLowerCase().includes(q) ||
|
||||
entry.examples.some((ex) => ex.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Источники (rule-sets)</h2>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 16 }}>
|
||||
Geo-базы в формате <strong>.srs</strong> (sing-box). Sing-box скачает их автоматически при применении.
|
||||
<strong> .dat файлы (v2ray) не поддерживаются</strong>.
|
||||
</small>
|
||||
|
||||
{ruleSets.length > 0 && (
|
||||
<>
|
||||
<div className="field-label" style={{ marginBottom: 6 }}>Подключённые</div>
|
||||
<table className="table" style={{ marginBottom: 20 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тег</th>
|
||||
<th>URL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ruleSets.map((rs) => (
|
||||
<tr key={rs.tag}>
|
||||
<td className="text-mono" style={{ whiteSpace: 'nowrap' }}>{rs.tag}</td>
|
||||
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn btn-ghost sm" style={{ marginRight: 4 }} onClick={() => setLookup(rs)} title="Просмотреть содержимое">🔍</button>
|
||||
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Каталог</div>
|
||||
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
placeholder="Поиск: telegram, реклама, youtube…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select className="select" style={{ width: 140 }} value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{CATALOG_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="muted" style={{ fontSize: 13, marginBottom: 12 }}>Ничего не найдено</div>
|
||||
)}
|
||||
{filtered.map((entry) => (
|
||||
<CatalogEntry
|
||||
key={entry.tag}
|
||||
entry={entry}
|
||||
added={ruleSets.some((rs) => rs.tag === entry.tag)}
|
||||
busy={busy}
|
||||
onAdd={addFromCatalog}
|
||||
onLookup={(e) => setLookup(e)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="field" style={{ marginTop: 16 }}>
|
||||
<span className="field-label">Добавить свой .srs</span>
|
||||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 200 }}
|
||||
placeholder="тег (напр. geosite-custom)"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
placeholder="https://…/rule-set.srs"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
|
||||
{lookup && (
|
||||
<RuleSetLookupModal
|
||||
tag={lookup.tag}
|
||||
url={lookup.url}
|
||||
onClose={() => setLookup(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PortsCard({ state }) {
|
||||
const isClient = state?.mode === 'client';
|
||||
return (
|
||||
<div className="card">
|
||||
<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">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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
state, subscriptionUrl, setSubscriptionUrl, busy,
|
||||
onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast,
|
||||
}) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<SubscriptionCard
|
||||
state={state}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
busy={busy}
|
||||
onFetch={onFetchSubscription}
|
||||
onForget={onForgetSubscription}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
<ConfigCard
|
||||
state={state}
|
||||
busy={busy}
|
||||
onShowConfig={onShowConfig}
|
||||
onClearConfig={onClearConfig}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
<RuleSetsCard pushToast={pushToast} />
|
||||
<PortsCard state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/web/components/Sidebar.jsx
Normal file
37
src/web/components/Sidebar.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'overview', label: 'Обзор', ico: '◉' },
|
||||
{ id: 'servers', label: 'Серверы', ico: '⋆' },
|
||||
{ id: 'routing', label: 'Маршрутизация', ico: '⇅' },
|
||||
{ id: 'logs', label: 'Логи', ico: '≡' },
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
{items.map((item) => {
|
||||
const badge = badges[item.id];
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`sidebar-item${active === item.id ? ' active' : ''}`}
|
||||
onClick={() => onChange(item.id)}
|
||||
>
|
||||
<span className="ico">{item.ico}</span>
|
||||
{item.label}
|
||||
{badge && (
|
||||
<span className={`badge ${badge.kind || ''}`}>{badge.text}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
91
src/web/components/StatusPane.jsx
Normal file
91
src/web/components/StatusPane.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
|
||||
function StatusRow({ label, value, kind }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<span className="key">{label}</span>
|
||||
<span className={`val ${kind ? 'text-' + kind : ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : '∞'}`
|
||||
: '—';
|
||||
|
||||
let singboxStatus = 'Остановлен';
|
||||
let singboxKind = 'muted';
|
||||
if (state?.singboxRunning) {
|
||||
singboxStatus = `работает · ${formatRelative(state.singboxStartedAt)}`;
|
||||
singboxKind = 'success';
|
||||
} else if (state?.configExists) {
|
||||
singboxStatus = 'остановлен (конфиг есть)';
|
||||
singboxKind = 'warning';
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="status-pane">
|
||||
<div className="card compact flat">
|
||||
<div className="card-header no-margin">
|
||||
<h3>sing-box</h3>
|
||||
<span className={`badge ${state?.singboxRunning ? 'success' : 'neutral'}`}>
|
||||
{state?.singboxRunning ? '● online' : '○ offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kv-list" style={{ marginTop: 12 }}>
|
||||
<StatusRow label="Статус" value={singboxStatus} kind={singboxKind} />
|
||||
<StatusRow label="UI порт" value={`:${state?.port || 3456}`} />
|
||||
<StatusRow label="Mixed proxy" value={`${state?.proxyBindIp || '0.0.0.0'}:${state?.proxyPort || 8080}`} />
|
||||
<StatusRow label="TProxy" value={`:${state?.tproxyPort || 7895}`} />
|
||||
<StatusRow label="RU direct" value={state?.routingRuDirect ? 'включено' : 'выключено'} />
|
||||
<StatusRow label="Трафик" value={traffic} />
|
||||
<StatusRow
|
||||
label="Применено"
|
||||
value={state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-group" style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-secondary sm block"
|
||||
disabled={busy || !state?.configExists}
|
||||
onClick={onRestart}
|
||||
>
|
||||
↻ Перезапустить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm block"
|
||||
disabled={busy || !state?.singboxRunning}
|
||||
onClick={onStop}
|
||||
>
|
||||
■ Остановить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm block"
|
||||
disabled={!state?.configExists}
|
||||
onClick={onShowConfig}
|
||||
>
|
||||
⌘ Показать config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state?.appliedHistory?.length > 0 && (
|
||||
<div className="card compact flat">
|
||||
<h4 style={{ marginBottom: 8 }}>История применений</h4>
|
||||
<div className="events-list">
|
||||
{state.appliedHistory.slice(0, 5).map((h) => (
|
||||
<div key={h.at} className="event-row" style={{ gridTemplateColumns: '1fr auto' }}>
|
||||
<span className="text-truncate">{h.tag}</span>
|
||||
<span className="event-time">{formatRelative(h.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
32
src/web/components/Toasts.jsx
Normal file
32
src/web/components/Toasts.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export function Toasts({ items, onDismiss }) {
|
||||
useEffect(() => {
|
||||
const timers = items.map((t) =>
|
||||
t.sticky ? null : setTimeout(() => onDismiss(t.id), t.duration || 4000),
|
||||
);
|
||||
return () => timers.forEach((t) => t && clearTimeout(t));
|
||||
}, [items, onDismiss]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="toasts">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.kind || ''}`}>
|
||||
<span className={`dot ${t.kind || ''}`} style={{ marginTop: 4 }} />
|
||||
<div className="body">
|
||||
<strong>{t.title}</strong>
|
||||
{t.message && <small>{t.message}</small>}
|
||||
{t.action && (
|
||||
<button className="btn btn-link sm" onClick={t.action.onClick} style={{ marginTop: 4, padding: 0 }}>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => onDismiss(t.id)} title="Закрыть">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/web/components/Topbar.jsx
Normal file
76
src/web/components/Topbar.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const map = {
|
||||
running: { dot: 'success', text: 'Работает', cls: '' },
|
||||
applying: { dot: 'warning pulse', text: 'Применяем…', cls: '' },
|
||||
error: { dot: 'danger', text: 'Ошибка', cls: '' },
|
||||
stopped: { dot: '', text: 'Остановлен', cls: '' },
|
||||
no_config: { dot: '', text: 'Не настроен', cls: '' },
|
||||
};
|
||||
const cfg = map[status] || map.stopped;
|
||||
return (
|
||||
<span className="flex">
|
||||
<span className={`dot ${cfg.dot}`} />
|
||||
<strong>{cfg.text}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApply }) {
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${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" />
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
<StatusBadge status={status} />
|
||||
{activeServer && (
|
||||
<div className="status-text">
|
||||
<strong>
|
||||
{flagFor(activeServer)} {activeServer.tag}
|
||||
</strong>
|
||||
<small>
|
||||
{activeServer.server}:{activeServer.server_port}
|
||||
{state?.appliedAt ? ` · применено ${formatRelative(state.appliedAt)}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{!activeServer && (
|
||||
<small className="muted">Сервер не выбран</small>
|
||||
)}
|
||||
{traffic && <span className="badge neutral">{traffic}</span>}
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{!isClient && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{!isClient && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
1105
src/web/styles.css
Normal file
1105
src/web/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
127
src/web/templates/ruleTemplates.js
Normal file
127
src/web/templates/ruleTemplates.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// Готовые шаблоны правил роутинга. domains/suffixes/cidr/ports собраны из публичных
|
||||
// reference-конфигов sing-box. Это пресеты «на старт», а не исчерпывающие списки.
|
||||
|
||||
let counter = 0;
|
||||
function id(prefix) {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now()}-${counter}`;
|
||||
}
|
||||
|
||||
function template(name, outbound, fields) {
|
||||
return {
|
||||
id: id("tpl"),
|
||||
name,
|
||||
enabled: true,
|
||||
outbound,
|
||||
domains: [],
|
||||
domainSuffixes: [],
|
||||
domainKeywords: [],
|
||||
ipCidrs: [],
|
||||
ports: [],
|
||||
networks: [],
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
export const ruleTemplates = [
|
||||
{
|
||||
key: "lol-direct",
|
||||
label: "League of Legends → direct",
|
||||
description: "Riot/LoL домены и порты — играть напрямую без VPN.",
|
||||
build: () =>
|
||||
template("League of Legends", "direct", {
|
||||
domainSuffixes: [
|
||||
"leagueoflegends.com",
|
||||
"riotgames.com",
|
||||
"riotcdn.net",
|
||||
"dyn.riotcdn.net",
|
||||
],
|
||||
ports: ["5000", "5223", "5222", "8088"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "discord-direct",
|
||||
label: "Discord/Vesktop → direct",
|
||||
description: "Discord voice/video и WebSocket напрямую.",
|
||||
build: () =>
|
||||
template("Discord", "direct", {
|
||||
domainSuffixes: [
|
||||
"discord.com",
|
||||
"discord.gg",
|
||||
"discord.media",
|
||||
"discordapp.com",
|
||||
"discordapp.net",
|
||||
],
|
||||
ports: ["50000-65535"],
|
||||
networks: ["udp"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "telegram-vpn",
|
||||
label: "Telegram → VPN",
|
||||
description: "Telegram через выбранный VPN outbound.",
|
||||
build: () =>
|
||||
template("Telegram", "vpn", {
|
||||
domainSuffixes: [
|
||||
"telegram.org",
|
||||
"t.me",
|
||||
"telegram.me",
|
||||
"telegra.ph",
|
||||
"tdesktop.com",
|
||||
],
|
||||
ipCidrs: [
|
||||
"149.154.160.0/20",
|
||||
"91.108.4.0/22",
|
||||
"91.108.8.0/22",
|
||||
"91.108.12.0/22",
|
||||
"91.108.16.0/22",
|
||||
"91.108.56.0/22",
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "youtube-vpn",
|
||||
label: "YouTube → VPN",
|
||||
description: "YouTube/Google Video через VPN.",
|
||||
build: () =>
|
||||
template("YouTube", "vpn", {
|
||||
domainSuffixes: [
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"ytimg.com",
|
||||
"googlevideo.com",
|
||||
"youtube-nocookie.com",
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "steam-direct",
|
||||
label: "Steam → direct",
|
||||
description: "Загрузка/обновления Steam напрямую.",
|
||||
build: () =>
|
||||
template("Steam", "direct", {
|
||||
domainSuffixes: [
|
||||
"steampowered.com",
|
||||
"steamcontent.com",
|
||||
"steamcommunity.com",
|
||||
"steamserver.net",
|
||||
"cm.steampowered.com",
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "ads-block",
|
||||
label: "Реклама → block",
|
||||
description: "Базовый набор рекламных доменов — заблокировать.",
|
||||
build: () =>
|
||||
template("Реклама (block)", "block", {
|
||||
domainSuffixes: [
|
||||
"doubleclick.net",
|
||||
"googlesyndication.com",
|
||||
"googleadservices.com",
|
||||
"adservice.google.com",
|
||||
"adnxs.com",
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
57
src/web/utils/clientRoute.js
Normal file
57
src/web/utils/clientRoute.js
Normal file
@@ -0,0 +1,57 @@
|
||||
export function resolveClientRoute({ state, activeServer } = {}) {
|
||||
const settings = state?.clientSettings || {};
|
||||
const localProxy = `127.0.0.1:${state?.proxyPort || settings.proxyPort || 8080}`;
|
||||
const running = Boolean(state?.singboxRunning);
|
||||
const hasConfig = Boolean(state?.configExists);
|
||||
|
||||
let mode = "none";
|
||||
let target = "выберите режим";
|
||||
let targetDetail = "Gateway, локальный VPN или напрямую";
|
||||
let title = "Не подключено";
|
||||
let description = "Выберите режим подключения и примените его.";
|
||||
let pathTarget = "не выбран";
|
||||
|
||||
if (settings.sharedProxyEnabled && settings.sharedProxy) {
|
||||
mode = "gateway";
|
||||
target = `${settings.sharedProxy.host}:${settings.sharedProxy.port}`;
|
||||
targetDetail = "общий gateway proxy";
|
||||
title = running ? "Подключено к gateway" : "Gateway настроен, но остановлен";
|
||||
description = "Локальный proxy на Mac отправляет трафик на серверный gateway.";
|
||||
pathTarget = `Gateway ${target}`;
|
||||
} else if (settings.homeBypassEnabled) {
|
||||
mode = "direct";
|
||||
target = "без VPN";
|
||||
targetDetail = "прямое подключение";
|
||||
title = running ? "Подключено напрямую" : "Direct настроен, но остановлен";
|
||||
description = "Приложения используют локальный proxy, но трафик идет напрямую.";
|
||||
pathTarget = "Direct";
|
||||
} else if (state?.selectedTag) {
|
||||
mode = "vpn";
|
||||
target = activeServer?.tag || state.selectedTag;
|
||||
targetDetail = "локальный VPN";
|
||||
title = running ? "Подключено через VPN" : "VPN настроен, но остановлен";
|
||||
description = "Локальный proxy на Mac отправляет трафик через выбранный VPN-сервер.";
|
||||
pathTarget = `VPN ${target}`;
|
||||
}
|
||||
|
||||
const status = running
|
||||
? "connected"
|
||||
: hasConfig && mode !== "none"
|
||||
? "stopped"
|
||||
: "empty";
|
||||
|
||||
if (status === "empty") {
|
||||
title = "Не подключено";
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
status,
|
||||
localProxy,
|
||||
title,
|
||||
target,
|
||||
targetDetail,
|
||||
description,
|
||||
path: ["Mac apps", localProxy, pathTarget, "Internet"],
|
||||
};
|
||||
}
|
||||
37
src/web/utils/country.js
Normal file
37
src/web/utils/country.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Грубое определение страны по тегу сервера и/или хосту.
|
||||
// Это эвристика — мы не делаем GeoIP-lookup.
|
||||
|
||||
const COUNTRIES = [
|
||||
{ re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" },
|
||||
{ re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" },
|
||||
{ re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" },
|
||||
{
|
||||
re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i,
|
||||
code: "US",
|
||||
flag: "🇺🇸",
|
||||
},
|
||||
{ re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" },
|
||||
{ re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" },
|
||||
{ re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },
|
||||
{ re: /\b(sg|singapore)\b/i, code: "SG", flag: "🇸🇬" },
|
||||
{ re: /\b(hk|hongkong|hong[-_ ]?kong)\b/i, code: "HK", flag: "🇭🇰" },
|
||||
{ re: /\b(fi|finland|helsinki)\b/i, code: "FI", flag: "🇫🇮" },
|
||||
{ re: /\b(se|sweden|stockholm)\b/i, code: "SE", flag: "🇸🇪" },
|
||||
{ re: /\b(pl|poland|warsaw)\b/i, code: "PL", flag: "🇵🇱" },
|
||||
{ re: /\b(tr|turkey|istanbul)\b/i, code: "TR", flag: "🇹🇷" },
|
||||
{ re: /\b(ua|ukraine|kiev|kyiv)\b/i, code: "UA", flag: "🇺🇦" },
|
||||
];
|
||||
|
||||
export function detectCountry(...inputs) {
|
||||
const text = inputs.filter(Boolean).join(" ").toLowerCase();
|
||||
for (const c of COUNTRIES) {
|
||||
if (c.re.test(text)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function flagFor(server) {
|
||||
if (!server) return "";
|
||||
const detected = detectCountry(server.tag, server.server);
|
||||
return detected?.flag || "🌐";
|
||||
}
|
||||
31
src/web/utils/format.js
Normal file
31
src/web/utils/format.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export function formatBytes(value) {
|
||||
if (!value) return "0 Б";
|
||||
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function formatRelative(iso) {
|
||||
if (!iso) return "";
|
||||
const ts = new Date(iso).getTime();
|
||||
if (Number.isNaN(ts)) return "";
|
||||
const diff = Math.max(0, Date.now() - ts);
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec} с назад`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} мин назад`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} ч назад`;
|
||||
const days = Math.floor(hr / 24);
|
||||
return `${days} дн назад`;
|
||||
}
|
||||
|
||||
export function formatTime(iso) {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
|
||||
}
|
||||
56
src/web/utils/validation.js
Normal file
56
src/web/utils/validation.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
|
||||
|
||||
const IPV4 =
|
||||
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
|
||||
const IPV6 = /^[0-9a-f:]+$/i;
|
||||
const DOMAIN =
|
||||
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
|
||||
export function invalidCidrs(values) {
|
||||
return (values || []).filter((value) => !isValidCidr(value));
|
||||
}
|
||||
|
||||
export function isValidCidr(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) return false;
|
||||
const [addr, mask] = trimmed.split("/");
|
||||
if (!addr) return false;
|
||||
|
||||
if (IPV4.test(addr)) {
|
||||
if (mask === undefined) return true;
|
||||
const m = Number(mask);
|
||||
return Number.isInteger(m) && m >= 0 && m <= 32;
|
||||
}
|
||||
if (IPV6.test(addr) && addr.includes(":")) {
|
||||
if (mask === undefined) return true;
|
||||
const m = Number(mask);
|
||||
return Number.isInteger(m) && m >= 0 && m <= 128;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function invalidPorts(values) {
|
||||
return (values || []).filter((value) => !isValidPort(value));
|
||||
}
|
||||
|
||||
export function isValidPort(value) {
|
||||
const n = Number.parseInt(String(value).trim(), 10);
|
||||
return Number.isInteger(n) && n > 0 && n <= 65535;
|
||||
}
|
||||
|
||||
export function invalidDomains(values) {
|
||||
return (values || []).filter((value) => !DOMAIN.test(String(value).trim()));
|
||||
}
|
||||
|
||||
export function ruleErrors(rule) {
|
||||
return {
|
||||
domains: invalidDomains(rule.domains),
|
||||
domainSuffixes: invalidDomains(rule.domainSuffixes),
|
||||
ipCidrs: invalidCidrs(rule.ipCidrs),
|
||||
ports: invalidPorts(rule.ports),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasErrors(errors) {
|
||||
return Object.values(errors).some((arr) => arr.length > 0);
|
||||
}
|
||||
55
test/server/shared-proxy.test.js
Normal file
55
test/server/shared-proxy.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const {
|
||||
buildSharedProxyInfo,
|
||||
checkSharedProxyGateway,
|
||||
} = await import("../../src/server/sharedProxy.js");
|
||||
|
||||
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||
const info = buildSharedProxyInfo({
|
||||
appMode: "gateway",
|
||||
proxyPort: 8080,
|
||||
running: true,
|
||||
hostHeader: "192.168.50.111:3456",
|
||||
});
|
||||
|
||||
assert.equal(info.available, true);
|
||||
assert.deepEqual(info.proxy, {
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
protocol: "socks5",
|
||||
httpUrl: "http://192.168.50.111:8080",
|
||||
socksUrl: "socks5://192.168.50.111:8080",
|
||||
});
|
||||
});
|
||||
|
||||
test("client shared proxy check normalizes gateway response into settings patch", async () => {
|
||||
const patch = await checkSharedProxyGateway(
|
||||
"http://192.168.50.111:3456",
|
||||
async (url) => {
|
||||
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
available: true,
|
||||
proxy: {
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
protocol: "socks5",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(patch.sharedProxyEnabled, true);
|
||||
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
|
||||
assert.deepEqual(patch.sharedProxy, {
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
protocol: "socks5",
|
||||
});
|
||||
});
|
||||
130
test/server/singbox-client-mode.test.js
Normal file
130
test/server/singbox-client-mode.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
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 clientSettingsPath = path.join(process.env.DATA_DIR, "client-settings.json");
|
||||
|
||||
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", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
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", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.deepEqual(config.route.rule_set, []);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("client home bypass routes the local proxy directly", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
fs.writeFileSync(
|
||||
clientSettingsPath,
|
||||
JSON.stringify({ homeBypassEnabled: true }),
|
||||
);
|
||||
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.deepEqual(config.route.rule_set, []);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "direct" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("client home bypass can build direct proxy without local VPN", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
fs.writeFileSync(
|
||||
clientSettingsPath,
|
||||
JSON.stringify({ homeBypassEnabled: true }),
|
||||
);
|
||||
|
||||
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
|
||||
|
||||
assert.deepEqual(config.outbounds, [
|
||||
{ type: "direct", tag: "direct" },
|
||||
{ type: "block", tag: "block" },
|
||||
]);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ 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" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("client shared proxy mode routes local proxy to gateway socks outbound", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
fs.writeFileSync(
|
||||
clientSettingsPath,
|
||||
JSON.stringify({
|
||||
sharedProxyEnabled: true,
|
||||
sharedProxy: {
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
protocol: "socks5",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
|
||||
|
||||
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]);
|
||||
assert.deepEqual(
|
||||
config.outbounds.find((outbound) => outbound.tag === "shared-proxy"),
|
||||
{
|
||||
type: "socks",
|
||||
tag: "shared-proxy",
|
||||
server: "192.168.50.111",
|
||||
server_port: 8080,
|
||||
version: "5",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "shared-proxy" },
|
||||
]);
|
||||
});
|
||||
92
test/web/client-route.test.js
Normal file
92
test/web/client-route.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveClientRoute } from "../../src/web/utils/clientRoute.js";
|
||||
|
||||
test("shows gateway route as the active Mac connection", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: true,
|
||||
proxyPort: 18080,
|
||||
clientSettings: {
|
||||
sharedProxyEnabled: true,
|
||||
sharedProxy: { host: "192.168.50.111", port: 8080, protocol: "socks5" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "gateway");
|
||||
assert.equal(route.status, "connected");
|
||||
assert.equal(route.title, "Подключено к gateway");
|
||||
assert.equal(route.target, "192.168.50.111:8080");
|
||||
assert.deepEqual(route.path, [
|
||||
"Mac apps",
|
||||
"127.0.0.1:18080",
|
||||
"Gateway 192.168.50.111:8080",
|
||||
"Internet",
|
||||
]);
|
||||
});
|
||||
|
||||
test("shows local VPN route with selected server", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: true,
|
||||
proxyPort: 8082,
|
||||
selectedTag: "nl-amsterdam",
|
||||
clientSettings: {},
|
||||
},
|
||||
activeServer: { tag: "nl-amsterdam", country: "NL" },
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "vpn");
|
||||
assert.equal(route.status, "connected");
|
||||
assert.equal(route.title, "Подключено через VPN");
|
||||
assert.equal(route.target, "nl-amsterdam");
|
||||
});
|
||||
|
||||
test("shows direct route when home mode is enabled", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: true,
|
||||
proxyPort: 8082,
|
||||
clientSettings: { homeBypassEnabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "direct");
|
||||
assert.equal(route.status, "connected");
|
||||
assert.equal(route.title, "Подключено напрямую");
|
||||
assert.equal(route.target, "без VPN");
|
||||
});
|
||||
|
||||
test("shows configured but stopped route clearly", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: false,
|
||||
configExists: true,
|
||||
proxyPort: 8082,
|
||||
selectedTag: "nl-amsterdam",
|
||||
clientSettings: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "vpn");
|
||||
assert.equal(route.status, "stopped");
|
||||
assert.equal(route.title, "VPN настроен, но остановлен");
|
||||
});
|
||||
|
||||
test("shows missing setup when nothing is configured", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: false,
|
||||
configExists: false,
|
||||
proxyPort: 8082,
|
||||
clientSettings: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "none");
|
||||
assert.equal(route.status, "empty");
|
||||
assert.equal(route.title, "Не подключено");
|
||||
assert.equal(route.target, "выберите режим");
|
||||
});
|
||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: '.',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
709
web/index.html
709
web/index.html
@@ -1,709 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VPN_CLIENT // SECURE_SHELL</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap');
|
||||
|
||||
:root {
|
||||
--color-neon: #00ff41;
|
||||
--color-bg: #050505;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-neon);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-neon);
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 65, 0.5);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(0, 255, 65, 0.02) 50%);
|
||||
background-size: 100% 4px;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.crt-flicker {
|
||||
animation: flicker 0.15s infinite;
|
||||
opacity: 0.1;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at center, transparent 80%, black 100%);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.matrix-bg {
|
||||
background-image: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.frame-corner {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-color: var(--color-neon);
|
||||
border-style: solid;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen flex flex-col relative selection:bg-[#00ff41] selection:text-black">
|
||||
|
||||
<!-- CRT Effects -->
|
||||
<div class="matrix-bg fixed inset-0 z-0 pointer-events-none"></div>
|
||||
<div
|
||||
class="fixed inset-0 z-40 pointer-events-none bg-[radial-gradient(circle_at_50%_50%,rgba(0,255,65,0.03)_0%,transparent_100%)]">
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="z-30 border-b border-[#00ff41]/20 bg-black/90 backdrop-blur-md sticky top-0">
|
||||
<div class="max-w-[1400px] mx-auto px-4 md:px-6 py-4 flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative p-1 border border-[#00ff41]/50 shadow-[0_0_10px_rgba(0,255,65,0.2)] bg-black">
|
||||
<i data-lucide="terminal" class="w-5 h-5 animate-pulse text-[#00ff41]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-black tracking-[0.2em] uppercase text-[#00ff41]">
|
||||
VPN<span class="text-white">_</span>CLIENT
|
||||
</h1>
|
||||
<p class="text-[9px] opacity-40 uppercase tracking-widest text-[#00ff41]">Secure Shell v4.2</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-6 text-[10px] uppercase">
|
||||
<div class="flex flex-col items-end border-r border-[#00ff41]/20 pr-6">
|
||||
<span class="opacity-30 text-[#00ff41]">Status</span>
|
||||
<span id="headerStatus" class="text-white font-bold">STANDBY</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="opacity-30 text-[#00ff41]">Traffic_Used</span>
|
||||
<span id="trafficValue" class="text-blue-400 font-bold">-- / --</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main
|
||||
class="flex-grow max-w-[1400px] w-full mx-auto px-4 md:px-6 py-6 grid grid-cols-1 lg:grid-cols-12 gap-6 relative z-10">
|
||||
|
||||
<!-- Left Column -->
|
||||
<div class="lg:col-span-8 flex flex-col gap-6">
|
||||
|
||||
<!-- Subscription Input -->
|
||||
<div class="flex flex-col gap-2 p-4 bg-[#0a0a0a] border border-[#00ff41]/30 relative group">
|
||||
<!-- Frame Corners -->
|
||||
<div class="absolute top-0 left-0 w-2 h-2 border-t border-l border-[#00ff41]"></div>
|
||||
<div class="absolute top-0 right-0 w-2 h-2 border-t border-r border-[#00ff41]"></div>
|
||||
<div class="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-[#00ff41]"></div>
|
||||
<div class="absolute bottom-0 right-0 w-2 h-2 border-b border-r border-[#00ff41]"></div>
|
||||
|
||||
<label
|
||||
class="text-[10px] uppercase tracking-widest opacity-50 text-[#00ff41]">Subscription_URL_Input</label>
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<div class="relative flex-grow">
|
||||
<i data-lucide="link"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#00ff41]/40"></i>
|
||||
<input type="text" id="subUrlInput" placeholder="https://provider.com/api/v1/config?key=..."
|
||||
class="w-full bg-black border border-[#00ff41]/20 py-2.5 pl-10 pr-4 text-xs tracking-wider focus:outline-none focus:border-[#00ff41] transition-all placeholder:text-[#00ff41]/20 text-[#00ff41]" />
|
||||
</div>
|
||||
<button id="fetchServersBtn"
|
||||
class="flex items-center justify-center gap-2 bg-[#00ff41] text-black px-6 py-2.5 text-xs font-black uppercase tracking-widest hover:bg-white hover:shadow-[0_0_15px_rgba(0,255,65,0.4)] transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i data-lucide="download" class="w-4 h-4" id="fetchIcon"></i>
|
||||
<span id="fetchText">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server List -->
|
||||
<div
|
||||
class="flex-grow bg-[#0a0a0a]/50 border border-[#00ff41]/10 overflow-hidden flex flex-col relative h-[500px] lg:h-auto">
|
||||
<div class="px-5 py-3 border-b border-[#00ff41]/10 bg-black flex justify-between items-center shrink-0">
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-[0.2em] font-bold text-[#00ff41]">Available_Endpoints</span>
|
||||
<span class="text-[9px] opacity-40 italic text-[#00ff41]">Click row to initiate link</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto custom-scrollbar flex-grow p-0">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<tbody id="serverListBody" class="divide-y divide-[#00ff41]/5">
|
||||
<!-- Populated by JS -->
|
||||
<tr class="text-[#00ff41]/30">
|
||||
<td colspan="4" class="p-8 text-center text-xs uppercase tracking-widest">
|
||||
No_Data_Stream // Awaiting_Sync
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="lg:col-span-4 flex flex-col gap-6">
|
||||
|
||||
<!-- Tunnel Intelligence (Status) -->
|
||||
<div id="statusBox"
|
||||
class="border-2 border-[#00ff41]/10 bg-black/40 p-6 relative transition-all duration-500 min-h-[200px]">
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<div class="w-1 h-3 border border-[#00ff41]/30 bg-[#00ff41]/10 blink-1"></div>
|
||||
<div class="w-1 h-3 border border-[#00ff41]/30 bg-[#00ff41]/10 blink-2"></div>
|
||||
<div class="w-1 h-3 border border-[#00ff41]/30 bg-[#00ff41]/10 blink-3"></div>
|
||||
</div>
|
||||
|
||||
<p class="text-[9px] uppercase tracking-widest mb-4 text-[#00ff41]">Tunnel_Intelligence</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="statusIconContainer"
|
||||
class="w-14 h-14 flex items-center justify-center border border-[#00ff41]/20 rounded-sm">
|
||||
<i data-lucide="shield-alert" class="w-6 h-6 text-[#00ff41]/50"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="statusTitle" class="text-2xl font-black italic tracking-tighter text-[#00ff41]/50">
|
||||
NO_SIGNAL
|
||||
</div>
|
||||
<div id="statusSubtitle"
|
||||
class="text-[10px] opacity-50 uppercase tracking-widest text-[#00ff41]">
|
||||
Link not initialized
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connectionDetails"
|
||||
class="grid grid-cols-2 gap-2 opacity-0 transition-opacity duration-300">
|
||||
<div class="p-2 border border-[#00ff41]/20 bg-black">
|
||||
<div class="text-[8px] opacity-40 uppercase flex items-center gap-1 text-[#00ff41]">
|
||||
<i data-lucide="zap" class="w-2 h-2"></i> Speed
|
||||
<button id="testSpeedBtn" class="ml-auto hover:text-white transition-colors"
|
||||
title="Run Speed Test">
|
||||
<i data-lucide="play" class="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="speedValue" class="text-xs font-bold tracking-widest text-[#00ff41]">-- Mb/s</div>
|
||||
</div>
|
||||
<div class="p-2 border border-[#00ff41]/20 bg-black">
|
||||
<div class="text-[8px] opacity-40 uppercase flex items-center gap-1 text-[#00ff41]">
|
||||
<i data-lucide="globe" class="w-2 h-2"></i> IP_ADDR
|
||||
</div>
|
||||
<div id="ipValue" class="text-xs font-bold tracking-widest text-[#00ff41]">HIDDEN</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Logs -->
|
||||
<div
|
||||
class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono h-[300px] lg:h-[400px]">
|
||||
<div
|
||||
class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
||||
<span
|
||||
class="text-[9px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
||||
<div class="w-1 h-1 bg-[#00ff41] rounded-full animate-ping"></div> Connection_Logs
|
||||
</span>
|
||||
<button id="clearLogs"
|
||||
class="text-[8px] opacity-30 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="logsContainer"
|
||||
class="flex-grow p-4 overflow-y-auto custom-scrollbar text-[10px] space-y-1.5 opacity-80 font-mono">
|
||||
<!-- Logs populated by JS -->
|
||||
<div class="flex gap-2">
|
||||
<span class="opacity-20 text-[#00ff41]">[SYSTEM]</span>
|
||||
<span class="text-[#00ff41] animate-pulse">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="z-30 bg-[#0d0d0d] border-t border-[#00ff41]/10 py-2 mt-auto">
|
||||
<div
|
||||
class="max-w-[1400px] mx-auto px-6 flex justify-between items-center text-[9px] uppercase tracking-[0.2em] opacity-40 text-[#00ff41]">
|
||||
<div class="flex gap-6">
|
||||
<span>Core: 4.1.0-Release</span>
|
||||
<span>Proxy: HTTP/8080</span>
|
||||
</div>
|
||||
<div class="hidden md:flex gap-6">
|
||||
<span>AES-256-GCM</span>
|
||||
<span class="text-[#00ff41] opacity-100 font-bold tracking-normal">SESSION: <span
|
||||
id="sessionId">...</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// --- Icons Initialization ---
|
||||
lucide.createIcons();
|
||||
|
||||
// --- State ---
|
||||
const state = {
|
||||
nodes: [],
|
||||
logs: [],
|
||||
activeNode: null,
|
||||
isFetching: false,
|
||||
isConnecting: false,
|
||||
subscriptionUrl: '',
|
||||
sessionId: Math.random().toString(16).substr(2, 8).toUpperCase(),
|
||||
userInfo: null
|
||||
};
|
||||
|
||||
// --- DOM Elements ---
|
||||
const els = {
|
||||
subUrlInput: document.getElementById('subUrlInput'),
|
||||
fetchServersBtn: document.getElementById('fetchServersBtn'),
|
||||
fetchIcon: document.getElementById('fetchIcon'),
|
||||
fetchText: document.getElementById('fetchText'),
|
||||
serverListBody: document.getElementById('serverListBody'),
|
||||
statusBox: document.getElementById('statusBox'),
|
||||
statusIconContainer: document.getElementById('statusIconContainer'),
|
||||
statusTitle: document.getElementById('statusTitle'),
|
||||
statusSubtitle: document.getElementById('statusSubtitle'),
|
||||
connectionDetails: document.getElementById('connectionDetails'),
|
||||
speedValue: document.getElementById('speedValue'),
|
||||
ipValue: document.getElementById('ipValue'),
|
||||
logsContainer: document.getElementById('logsContainer'),
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
headerStatus: document.getElementById('headerStatus'),
|
||||
sessionId: document.getElementById('sessionId'),
|
||||
trafficValue: document.getElementById('trafficValue'),
|
||||
testSpeedBtn: document.getElementById('testSpeedBtn')
|
||||
};
|
||||
|
||||
els.sessionId.textContent = state.sessionId;
|
||||
|
||||
// --- Helpers ---
|
||||
function formatBytes(bytes, decimals = 1) {
|
||||
if (!+bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// --- Logger ---
|
||||
function addLog(msg, type = 'info') {
|
||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
||||
|
||||
const logEl = document.createElement('div');
|
||||
logEl.className = 'flex gap-2 items-start leading-tight animate-in fade-in slide-in-from-left-1 duration-300';
|
||||
|
||||
let colorClass = 'text-[#00ff41]/70';
|
||||
let prefix = '>>';
|
||||
if (type === 'success') { colorClass = 'text-blue-400'; prefix = 'OK.'; }
|
||||
if (type === 'warning') { colorClass = 'text-yellow-500'; prefix = '!!'; }
|
||||
if (type === 'error') { colorClass = 'text-red-500'; prefix = 'ERR'; }
|
||||
|
||||
logEl.innerHTML = `
|
||||
<span class="opacity-20 shrink-0 tracking-tighter text-[#00ff41]">[${time}]</span>
|
||||
<span class="${colorClass}">${prefix} ${msg}</span>
|
||||
`;
|
||||
|
||||
els.logsContainer.insertBefore(logEl, els.logsContainer.lastElementChild);
|
||||
els.logsContainer.scrollTop = els.logsContainer.scrollHeight;
|
||||
|
||||
// Limit logs
|
||||
while (els.logsContainer.children.length > 50) {
|
||||
els.logsContainer.removeChild(els.logsContainer.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
els.clearLogs.addEventListener('click', () => {
|
||||
const lastChild = els.logsContainer.lastElementChild;
|
||||
els.logsContainer.innerHTML = '';
|
||||
els.logsContainer.appendChild(lastChild);
|
||||
addLog('LOGS_CLEARED_BY_USER', 'info');
|
||||
});
|
||||
|
||||
|
||||
// --- UI Rendering ---
|
||||
function renderNodes() {
|
||||
els.serverListBody.innerHTML = '';
|
||||
if (state.nodes.length === 0) {
|
||||
els.serverListBody.innerHTML = `
|
||||
<tr class="text-[#00ff41]/30">
|
||||
<td colspan="4" class="p-8 text-center text-xs uppercase tracking-widest">
|
||||
No_Nodes_Found // Sync_Required
|
||||
</td>
|
||||
</tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
state.nodes.forEach((node, index) => {
|
||||
const isActive = state.activeNode && state.activeNode.tag === node.tag;
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `group cursor-pointer transition-all border-b border-[#00ff41]/5 hover:bg-[#00ff41]/5 ${isActive ? 'bg-[#00ff41]/10' : ''}`;
|
||||
tr.onclick = () => handleConnect(node);
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="p-4 w-12">
|
||||
<div class="w-2 h-2 rounded-full bg-[#00ff41] ${isActive ? 'animate-ping' : ''}"></div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-bold text-white uppercase tracking-wider">${node.tag}</span>
|
||||
<span class="text-[9px] opacity-40 uppercase text-[#00ff41]">${node.type} // ${node.server}:${node.port}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-[9px] opacity-40 uppercase text-[#00ff41]/50">Ping</span>
|
||||
<span id="ping-${index}" class="text-xs font-mono text-[#00ff41]">--</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 text-right">
|
||||
${isActive
|
||||
? '<span class="text-[10px] font-black animate-pulse text-white">[ CONNECTED ]</span>'
|
||||
: '<i data-lucide="chevron-right" class="w-4 h-4 text-[#00ff41] opacity-10 group-hover:opacity-100 group-hover:translate-x-1 transition-all inline-block"></i>'
|
||||
}
|
||||
</td>
|
||||
`;
|
||||
els.serverListBody.appendChild(tr);
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function updateStatusUI() {
|
||||
if (state.activeNode) {
|
||||
// Active Styling
|
||||
els.statusBox.classList.remove('border-[#00ff41]/10', 'bg-black/40');
|
||||
els.statusBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/5', 'shadow-[inset_0_0_20px_rgba(0,255,65,0.05)]');
|
||||
|
||||
// Icon
|
||||
if (state.isConnecting) {
|
||||
els.statusIconContainer.innerHTML = '<i data-lucide="refresh-cw" class="w-6 h-6 text-[#00ff41] animate-spin"></i>';
|
||||
els.statusTitle.textContent = 'HANDSHAKE';
|
||||
els.statusSubtitle.textContent = 'Negotiating keys...';
|
||||
els.headerStatus.textContent = 'NEGOTIATING';
|
||||
} else {
|
||||
els.statusIconContainer.innerHTML = '<i data-lucide="shield-check" class="w-6 h-6 text-[#00ff41]"></i>';
|
||||
els.statusIconContainer.classList.add('shadow-[0_0_15px_#00ff4166]', 'border-[#00ff41]');
|
||||
els.statusTitle.textContent = 'ENCRYPTED';
|
||||
els.statusTitle.classList.remove('text-[#00ff41]/50');
|
||||
els.statusTitle.classList.add('text-[#00ff41]');
|
||||
els.statusSubtitle.textContent = `${state.activeNode.tag} // SECURE`;
|
||||
els.connectionDetails.classList.remove('opacity-0');
|
||||
els.headerStatus.textContent = 'TUNNEL_UP';
|
||||
els.headerStatus.classList.add('text-[#00ff41]');
|
||||
}
|
||||
} else {
|
||||
// Inactive Styling
|
||||
els.statusBox.classList.add('border-[#00ff41]/10', 'bg-black/40');
|
||||
els.statusBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/5', 'shadow-[inset_0_0_20px_rgba(0,255,65,0.05)]');
|
||||
|
||||
els.statusIconContainer.innerHTML = '<i data-lucide="shield-alert" class="w-6 h-6 text-[#00ff41]/50"></i>';
|
||||
els.statusIconContainer.classList.remove('shadow-[0_0_15px_#00ff4166]', 'border-[#00ff41]');
|
||||
els.statusTitle.textContent = 'NO_SIGNAL';
|
||||
els.statusTitle.classList.add('text-[#00ff41]/50');
|
||||
els.statusTitle.classList.remove('text-[#00ff41]');
|
||||
els.statusSubtitle.textContent = 'Link not initialized';
|
||||
els.connectionDetails.classList.add('opacity-0');
|
||||
els.headerStatus.textContent = 'STANDBY';
|
||||
els.headerStatus.classList.remove('text-[#00ff41]');
|
||||
|
||||
// Reset stats
|
||||
els.speedValue.textContent = '-- Mb/s';
|
||||
els.ipValue.textContent = 'HIDDEN';
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function updateTrafficUI(info) {
|
||||
if (!info) return;
|
||||
const used = formatBytes((info.download || 0) + (info.upload || 0));
|
||||
const total = info.total ? formatBytes(info.total) : '∞';
|
||||
els.trafficValue.textContent = `${used} / ${total}`;
|
||||
}
|
||||
|
||||
async function checkServerLatencies(nodes) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const pingEl = document.getElementById(`ping-${i}`);
|
||||
if (pingEl) pingEl.textContent = '...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/ping-target', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ server: node.server, port: node.server_port || 443 })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (pingEl) {
|
||||
if (data.latency && data.latency !== -1) {
|
||||
pingEl.textContent = data.latency + 'ms';
|
||||
if (data.latency > 300) pingEl.style.color = 'rgb(239, 68, 68)'; // red
|
||||
else if (data.latency < 100) pingEl.style.color = '#00ff41'; // green
|
||||
else pingEl.style.color = 'rgb(234, 179, 8)'; // yellow
|
||||
} else {
|
||||
pingEl.textContent = 'Timeout';
|
||||
pingEl.style.color = 'rgb(239, 68, 68)';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (pingEl) pingEl.textContent = 'Err';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConnectionSpeed(fullTest = false) {
|
||||
if (fullTest) els.speedValue.textContent = 'TESTING...';
|
||||
else els.speedValue.textContent = 'PAUSED';
|
||||
|
||||
els.ipValue.textContent = '...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/test-connection?speed=${fullTest}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
els.ipValue.textContent = 'ERROR';
|
||||
if (fullTest) els.speedValue.textContent = 'ERROR';
|
||||
} else {
|
||||
els.ipValue.textContent = data.ip;
|
||||
if (data.speed) {
|
||||
els.speedValue.textContent = data.speed;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (fullTest) els.speedValue.textContent = 'NET_ERR';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async function handleFetchNodes() {
|
||||
const url = els.subUrlInput.value.trim();
|
||||
if (!url) {
|
||||
addLog('ERROR_MISSING_URL_INPUT', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.isFetching = true;
|
||||
els.fetchIcon.classList.add('hidden');
|
||||
els.fetchText.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 animate-spin"></i>';
|
||||
lucide.createIcons();
|
||||
|
||||
addLog(`FETCHING_CONFIG: ${url.substring(0, 25)}...`, 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch('/fetch-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.servers) {
|
||||
state.nodes = data.servers;
|
||||
state.config = data.config;
|
||||
state.subscriptionUrl = url;
|
||||
state.userInfo = data.userInfo;
|
||||
|
||||
renderNodes();
|
||||
updateTrafficUI(state.userInfo);
|
||||
addLog(`SYNC_COMPLETE: ${state.nodes.length} Endpoints Retrieved`, 'success');
|
||||
|
||||
// Trigger Ping Check
|
||||
addLog('INITIATING_LATENCY_CHECK...', 'info');
|
||||
checkServerLatencies(state.nodes);
|
||||
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown Error');
|
||||
}
|
||||
} catch (e) {
|
||||
addLog(`SYNC_FAILED: ${e.message}`, 'error');
|
||||
} finally {
|
||||
state.isFetching = false;
|
||||
els.fetchIcon.classList.remove('hidden');
|
||||
els.fetchText.textContent = 'Sync';
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect(node) {
|
||||
if (state.activeNode && state.activeNode.tag === node.tag && !state.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeNode = node;
|
||||
state.isConnecting = true;
|
||||
updateStatusUI();
|
||||
renderNodes(); // to update active styling
|
||||
|
||||
addLog(`INITIATING_HANDSHAKE: ${node.tag}`, 'warning');
|
||||
|
||||
try {
|
||||
const res = await fetch('/apply-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: state.config,
|
||||
selectedServer: node.tag,
|
||||
subUrl: state.subscriptionUrl,
|
||||
userInfo: state.userInfo // save user info if needed
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setTimeout(() => {
|
||||
state.isConnecting = false;
|
||||
updateStatusUI();
|
||||
addLog(`ENCRYPTED_SESSION_ESTABLISHED_${node.tag.toUpperCase()}`, 'success');
|
||||
|
||||
// Check IP but skip Speed Test
|
||||
addLog('VERIFYING_TUNNEL_IP...', 'info');
|
||||
checkConnectionSpeed(false);
|
||||
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
state.isConnecting = false;
|
||||
state.activeNode = null;
|
||||
updateStatusUI();
|
||||
renderNodes();
|
||||
addLog(`HANDSHAKE_FAILED: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.active && data.tag) {
|
||||
const currentTag = state.activeNode ? state.activeNode.tag : null;
|
||||
|
||||
if (currentTag !== data.tag) {
|
||||
// Find full node info if available
|
||||
const fullNode = state.nodes.find(n => n.tag === data.tag);
|
||||
|
||||
if (fullNode) {
|
||||
state.activeNode = fullNode;
|
||||
} else {
|
||||
state.activeNode = { tag: data.tag, server: data.server, port: '?', type: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
updateStatusUI();
|
||||
renderNodes(); // Update list highlighting
|
||||
|
||||
if (els.speedValue.textContent === '-- Mb/s' || els.ipValue.textContent === 'HIDDEN') {
|
||||
checkConnectionSpeed(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSaved() {
|
||||
try {
|
||||
addLog('SYSTEM_BOOT_SEQUENCE_INITIATED...', 'info');
|
||||
const res = await fetch('/subscription');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.saved && data.url) {
|
||||
els.subUrlInput.value = data.url;
|
||||
state.userInfo = data.userInfo;
|
||||
updateTrafficUI(state.userInfo);
|
||||
|
||||
// Auto-sync
|
||||
await handleFetchNodes();
|
||||
|
||||
if (data.selectedServer) {
|
||||
const savedNode = state.nodes.find(n => n.tag === data.selectedServer);
|
||||
if (savedNode) {
|
||||
// Will be handled by fetchStatus
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addLog('NO_SAVED_CONFIG_FOUND', 'warning');
|
||||
}
|
||||
|
||||
await fetchStatus();
|
||||
|
||||
} catch (e) {
|
||||
addLog('SYSTEM_BOOT_ERROR', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
els.fetchServersBtn.addEventListener('click', handleFetchNodes);
|
||||
|
||||
els.testSpeedBtn.addEventListener('click', () => {
|
||||
addLog('MANUAL_SPEED_TEST_INITIATED', 'info');
|
||||
checkConnectionSpeed(true);
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
addLog('TERMINAL_READY', 'info');
|
||||
loadSaved();
|
||||
|
||||
// Blink animation style
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.blink-1 { animation: blink 1s infinite; }
|
||||
.blink-2 { animation: blink 1s infinite 0.2s; }
|
||||
.blink-3 { animation: blink 1s infinite 0.4s; }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
746
web/server.py
746
web/server.py
@@ -1,746 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple HTTP Web Server for VPN Proxy Control
|
||||
Provides a web UI to manage sing-box subscriptions
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socketserver
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import parse_qs, unquote
|
||||
from pathlib import Path
|
||||
|
||||
PORT = int(os.environ.get("PORT", 3456))
|
||||
PROXY_PORT = int(os.environ.get("PROXY_PORT", 8080))
|
||||
APP_NAME = "VPN-Proxy-Control by Dokril"
|
||||
APP_DIR = Path(__file__).parent
|
||||
BASE_DIR = APP_DIR.parent
|
||||
WEB_DIR = APP_DIR
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
CONFIG_FILE = DATA_DIR / "client.json"
|
||||
HWID_FILE = DATA_DIR / "hwid"
|
||||
SUBSCRIPTION_FILE = DATA_DIR / "subscription.json"
|
||||
|
||||
|
||||
def get_hwid() -> str:
|
||||
"""Get or generate hardware ID"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if HWID_FILE.exists():
|
||||
return HWID_FILE.read_text().strip()
|
||||
|
||||
# Generate new random HWID
|
||||
hwid = uuid.uuid4().hex[:16]
|
||||
HWID_FILE.write_text(hwid)
|
||||
return hwid
|
||||
|
||||
|
||||
def get_system_info() -> dict:
|
||||
"""Get system information for headers"""
|
||||
system = platform.system().lower() # windows, linux, darwin
|
||||
version = platform.release() # 10, 5.15.0, 22.0.0
|
||||
|
||||
return {
|
||||
"os": system,
|
||||
"version": version
|
||||
}
|
||||
|
||||
|
||||
def save_subscription(url: str, selected_server: str = None, user_info: dict = None):
|
||||
"""Save subscription URL, selected server and user info to file"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"url": url,
|
||||
"selectedServer": selected_server,
|
||||
"userInfo": user_info
|
||||
}
|
||||
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def load_subscription() -> dict:
|
||||
"""Load subscription from file"""
|
||||
if SUBSCRIPTION_FILE.exists():
|
||||
try:
|
||||
return json.loads(SUBSCRIPTION_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def measure_tcp_latency(host: str, port: int, timeout: float = 2.0) -> int:
|
||||
"""Measure TCP latency to a host:port in milliseconds"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
latency = (time.time() - start_time) * 1000
|
||||
return int(latency)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
|
||||
def measure_proxy_performance(enable_speed_test: bool = False) -> dict:
|
||||
"""Measure proxy latency, speed and public IP via local proxy"""
|
||||
proxy_url = f"http://127.0.0.1:{PROXY_PORT}"
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
|
||||
# 1. Measure Latency (Ping)
|
||||
latency = "Timeout"
|
||||
try:
|
||||
start_time = time.time()
|
||||
# Use a reliable endpoint for ping
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
||||
req = urllib.request.Request("http://www.gstatic.com/generate_204", headers={"User-Agent": "singbox-test"})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
lat_ms = int((time.time() - start_time) * 1000)
|
||||
latency = f"{lat_ms}ms"
|
||||
except Exception as e:
|
||||
latency = "Error"
|
||||
|
||||
# 2. Get Public IP (IPv4)
|
||||
ip = "Unknown"
|
||||
try:
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
||||
# Use v4.ident.me to force IPv4
|
||||
req = urllib.request.Request("http://v4.ident.me", headers={"User-Agent": "curl/7.68.0"})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
ip = response.read().decode('utf-8').strip()
|
||||
except Exception:
|
||||
# Fallback to ipify if ident.me fails or returns garbage
|
||||
try:
|
||||
req = urllib.request.Request("http://api.ipify.org", headers={"User-Agent": "curl/7.68.0"})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
ip = response.read().decode('utf-8').strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Measure Download Speed
|
||||
speed_mbps = 0.0
|
||||
if enable_speed_test:
|
||||
test_files = [
|
||||
# Tele2 Speedtest (Usually very reliable and fast)
|
||||
("https://speedtest.selectel.ru/100MB", 100),
|
||||
# ThinkBroadband (Reliable backup)
|
||||
("https://speedtest.selectel.ru/1GB", 1000)
|
||||
]
|
||||
|
||||
for url, size_mb in test_files:
|
||||
try:
|
||||
print(f"[WebUI] Testing speed with: {url}")
|
||||
start_time = time.time()
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
||||
# Set a longer timeout for speed tests
|
||||
with opener.open(url, timeout=30) as response:
|
||||
downloaded = 0
|
||||
# Larger chunk size for better throughput measurement
|
||||
chunk_size = 1024 * 256 # 256KB chunks
|
||||
|
||||
# Download for at least 2 seconds or up to 25MB for accurate measurement
|
||||
min_test_duration = 2.0 # seconds
|
||||
max_download_bytes = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
downloaded += len(chunk)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
# Stop if we've downloaded enough AND tested for minimum duration
|
||||
if downloaded >= max_download_bytes or (elapsed >= min_test_duration and downloaded >= 2 * 1024 * 1024):
|
||||
break
|
||||
|
||||
duration = time.time() - start_time
|
||||
if duration > 0.1 and downloaded > 0:
|
||||
# Calculate speed in Mbps (megabits per second)
|
||||
# downloaded bytes * 8 bits/byte / 1,000,000 / seconds
|
||||
speed_mbps = round((downloaded * 8) / (1000 * 1000) / duration, 1)
|
||||
print(f"[WebUI] Speed test: downloaded {downloaded / (1024*1024):.1f}MB in {duration:.1f}s = {speed_mbps} Mbps")
|
||||
break # Stop if successful
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Speed test failed for {url}: {e}")
|
||||
continue
|
||||
|
||||
result = {
|
||||
"latency": latency,
|
||||
"ip": ip
|
||||
}
|
||||
|
||||
if enable_speed_test:
|
||||
# If speed is still 0.0 but we tried, return Error or 0.0
|
||||
result["speed"] = f"{speed_mbps} Mbps"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_vless_url(url: str) -> dict:
|
||||
"""Parse VLESS URL and extract connection parameters"""
|
||||
if not url.startswith("vless://"):
|
||||
raise ValueError("URL must start with vless://")
|
||||
|
||||
# Remove scheme
|
||||
url_no_scheme = url[8:]
|
||||
|
||||
# Split by fragment (#tag)
|
||||
if '#' in url_no_scheme:
|
||||
url_part, tag = url_no_scheme.split('#', 1)
|
||||
tag = unquote(tag)
|
||||
else:
|
||||
url_part = url_no_scheme
|
||||
tag = "reality"
|
||||
|
||||
# Split by query (?)
|
||||
if '?' in url_part:
|
||||
uuid_host_port, query_string = url_part.split('?', 1)
|
||||
else:
|
||||
raise ValueError("Missing query parameters")
|
||||
|
||||
# Parse UUID@host:port
|
||||
if '@' not in uuid_host_port:
|
||||
raise ValueError("Missing @ separator")
|
||||
|
||||
uuid_str, host_port = uuid_host_port.split('@', 1)
|
||||
|
||||
if ':' not in host_port:
|
||||
raise ValueError("Missing port")
|
||||
|
||||
host, port_str = host_port.rsplit(':', 1)
|
||||
port = int(port_str)
|
||||
|
||||
# Parse query parameters
|
||||
params = {}
|
||||
for param in query_string.split('&'):
|
||||
if '=' in param:
|
||||
key, value = param.split('=', 1)
|
||||
params[key] = unquote(value)
|
||||
|
||||
# Extract required parameters
|
||||
pbk = params.get('pbk', '')
|
||||
sid = params.get('sid', '')
|
||||
sni = params.get('sni', host)
|
||||
fp = params.get('fp', 'chrome')
|
||||
flow = params.get('flow', '')
|
||||
|
||||
if not pbk or not sid:
|
||||
raise ValueError("Missing required parameters: pbk or sid")
|
||||
|
||||
return {
|
||||
'uuid': uuid_str,
|
||||
'server': host,
|
||||
'server_port': port,
|
||||
'tag': tag,
|
||||
'public_key': pbk,
|
||||
'short_id': sid,
|
||||
'server_name': sni,
|
||||
'fingerprint': fp,
|
||||
'flow': flow
|
||||
}
|
||||
|
||||
|
||||
def generate_vless_config(vless_params: dict) -> dict:
|
||||
"""Generate sing-box configuration from VLESS parameters"""
|
||||
config = {
|
||||
"dns": {
|
||||
"independent_cache": True
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"disabled": True,
|
||||
"timestamp": True
|
||||
},
|
||||
"route": {
|
||||
"final": vless_params['tag'],
|
||||
"auto_detect_interface": True
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "mixed-in",
|
||||
"type": "mixed",
|
||||
"sniff": True,
|
||||
"users": [],
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": PROXY_PORT,
|
||||
"set_system_proxy": False
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": vless_params['tag'],
|
||||
"server": vless_params['server'],
|
||||
"server_port": vless_params['server_port'],
|
||||
"flow": vless_params['flow'],
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": vless_params['server_name'],
|
||||
"reality": {
|
||||
"enabled": True,
|
||||
"public_key": vless_params['public_key'],
|
||||
"short_id": vless_params['short_id']
|
||||
},
|
||||
"utls": {
|
||||
"enabled": True,
|
||||
"fingerprint": vless_params['fingerprint']
|
||||
}
|
||||
},
|
||||
"uuid": vless_params['uuid']
|
||||
},
|
||||
{
|
||||
"tag": "direct",
|
||||
"type": "direct"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class ThreadingHTTPServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP Request Handler for Proxy Control"""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Override to add timestamp prefix"""
|
||||
print(f"[WebUI] {args[0]}")
|
||||
|
||||
def send_json(self, data: dict, status: int = 200):
|
||||
"""Send JSON response"""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def send_html(self, content: bytes):
|
||||
"""Send HTML response"""
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests"""
|
||||
if self.path == "/" or self.path == "/index.html":
|
||||
self.serve_index()
|
||||
elif self.path == "/status":
|
||||
self.get_status()
|
||||
elif self.path == "/subscription":
|
||||
self.get_subscription()
|
||||
elif self.path.startswith("/test-connection"):
|
||||
self.test_connection()
|
||||
elif self.path.startswith("/static/"):
|
||||
self.serve_static()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST requests"""
|
||||
if self.path == "/apply":
|
||||
self.apply_config()
|
||||
elif self.path == "/fetch-subscription":
|
||||
self.fetch_subscription()
|
||||
elif self.path == "/apply-subscription":
|
||||
self.apply_subscription()
|
||||
elif self.path == "/ping-target":
|
||||
self.ping_target()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def serve_index(self):
|
||||
"""Serve main HTML page"""
|
||||
index_path = WEB_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
self.send_html(index_path.read_bytes())
|
||||
else:
|
||||
self.send_error(404, "index.html not found")
|
||||
|
||||
def serve_static(self):
|
||||
"""Serve static files"""
|
||||
file_path = WEB_DIR / self.path[8:] # Remove /static/
|
||||
if file_path.exists() and file_path.is_file():
|
||||
content_type = "text/css" if str(file_path).endswith(".css") else "application/javascript"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.end_headers()
|
||||
self.wfile.write(file_path.read_bytes())
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def get_status(self):
|
||||
"""Get current proxy status"""
|
||||
config_exists = CONFIG_FILE.exists()
|
||||
current_tag = None
|
||||
current_server = None
|
||||
|
||||
if config_exists:
|
||||
try:
|
||||
config = json.loads(CONFIG_FILE.read_text())
|
||||
for outbound in config.get("outbounds", []):
|
||||
if outbound.get("type") == "vless":
|
||||
current_tag = outbound.get("tag", "unknown")
|
||||
current_server = outbound.get("server", "unknown")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.send_json({
|
||||
"active": config_exists,
|
||||
"tag": current_tag,
|
||||
"server": current_server
|
||||
})
|
||||
|
||||
def get_subscription(self):
|
||||
"""Get saved subscription info"""
|
||||
sub = load_subscription()
|
||||
if sub:
|
||||
self.send_json({
|
||||
"saved": True,
|
||||
"url": sub.get("url"),
|
||||
"selectedServer": sub.get("selectedServer"),
|
||||
"userInfo": sub.get("userInfo")
|
||||
})
|
||||
else:
|
||||
self.send_json({"saved": False})
|
||||
|
||||
def test_connection(self):
|
||||
"""Test active proxy connection"""
|
||||
query_components = {}
|
||||
if '?' in self.path:
|
||||
_, query = self.path.split('?', 1)
|
||||
query_components = parse_qs(query)
|
||||
|
||||
enable_speed = query_components.get('speed', ['false'])[0].lower() == 'true'
|
||||
|
||||
result = measure_proxy_performance(enable_speed_test=enable_speed)
|
||||
self.send_json(result)
|
||||
|
||||
def ping_target(self):
|
||||
"""Ping a specific target"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
|
||||
server = data.get("server")
|
||||
port = int(data.get("port", 443))
|
||||
|
||||
if not server:
|
||||
self.send_json({"error": "No server specified"}, 400)
|
||||
return
|
||||
|
||||
latency = measure_tcp_latency(server, port)
|
||||
self.send_json({"latency": latency})
|
||||
|
||||
except Exception as e:
|
||||
self.send_json({"error": str(e)}, 500)
|
||||
|
||||
def apply_config(self):
|
||||
"""Apply new config from VLESS URL"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
url = data.get("url", "").strip()
|
||||
|
||||
if not url:
|
||||
self.send_json({"success": False, "error": "URL не указан"}, 400)
|
||||
return
|
||||
|
||||
if not url.startswith("vless://"):
|
||||
self.send_json({"success": False, "error": "Неверный формат. Поддерживаются только vless:// ссылки"}, 400)
|
||||
return
|
||||
|
||||
# Parse VLESS URL
|
||||
try:
|
||||
vless_params = parse_vless_url(url)
|
||||
except ValueError as e:
|
||||
self.send_json({"success": False, "error": f"Ошибка парсинга URL: {str(e)}"}, 400)
|
||||
return
|
||||
|
||||
# Generate config
|
||||
config = generate_vless_config(vless_params)
|
||||
|
||||
# Ensure data directory exists
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write config file
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
# Trigger reload via internal control port
|
||||
try:
|
||||
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": f"Конфигурация '{vless_params['tag']}' успешно применена!"
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
def fetch_subscription(self):
|
||||
"""Fetch servers list from subscription URL"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
url = data.get("url", "").strip()
|
||||
|
||||
if not url:
|
||||
self.send_json({"success": False, "error": "URL подписки не указан"}, 400)
|
||||
return
|
||||
|
||||
# Fetch subscription config
|
||||
sys_info = get_system_info()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "singbox",
|
||||
"x-hwid": get_hwid(),
|
||||
"x-device-os": sys_info["os"],
|
||||
"x-ver-os": sys_info["version"],
|
||||
"x-device-model": APP_NAME
|
||||
}
|
||||
)
|
||||
|
||||
config = None
|
||||
config_text = ""
|
||||
user_info = {}
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
config_text = response.read().decode("utf-8")
|
||||
|
||||
# Parse User Info header
|
||||
user_info_header = response.headers.get("subscription-userinfo", "")
|
||||
if user_info_header:
|
||||
parts = user_info_header.split(';')
|
||||
for part in parts:
|
||||
if '=' in part:
|
||||
key, value = part.strip().split('=', 1)
|
||||
try:
|
||||
user_info[key] = int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
self.send_json({"success": False, "error": f"Ошибка HTTP: {e.code}"}, 400)
|
||||
return
|
||||
except urllib.error.URLError as e:
|
||||
self.send_json({"success": False, "error": f"Ошибка подключения: {e.reason}"}, 400)
|
||||
return
|
||||
|
||||
# Try to parse as JSON first
|
||||
try:
|
||||
config = json.loads(config_text)
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - try Base64 decode or plain text VLESS links
|
||||
content = config_text.strip()
|
||||
|
||||
# Try Base64 decode
|
||||
import base64
|
||||
import re
|
||||
try:
|
||||
# Check if it looks like Base64
|
||||
if re.match(r'^[A-Za-z0-9+/=\s]+$', content):
|
||||
decoded = base64.b64decode(content).decode('utf-8')
|
||||
content = decoded
|
||||
except Exception:
|
||||
pass # Not Base64, continue with original content
|
||||
|
||||
# Parse VLESS links
|
||||
lines = content.strip().split('\n')
|
||||
vless_links = [line.strip() for line in lines if line.strip().startswith('vless://')]
|
||||
|
||||
if not vless_links:
|
||||
self.send_json({"success": False, "error": "Не найдены VLESS ссылки в ответе"}, 400)
|
||||
return
|
||||
|
||||
# Parse each VLESS link and create outbounds
|
||||
outbounds = []
|
||||
for link in vless_links:
|
||||
try:
|
||||
params = parse_vless_url(link)
|
||||
outbound = {
|
||||
"type": "vless",
|
||||
"tag": params['tag'],
|
||||
"server": params['server'],
|
||||
"server_port": params['server_port'],
|
||||
"uuid": params['uuid'],
|
||||
"flow": params['flow'],
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": params['server_name'],
|
||||
"utls": {"enabled": True, "fingerprint": params['fingerprint']},
|
||||
"reality": {
|
||||
"enabled": True,
|
||||
"public_key": params['public_key'],
|
||||
"short_id": params['short_id']
|
||||
}
|
||||
},
|
||||
"packet_encoding": "xudp"
|
||||
}
|
||||
outbounds.append(outbound)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Failed to parse VLESS link: {e}")
|
||||
continue
|
||||
|
||||
if not outbounds:
|
||||
self.send_json({"success": False, "error": "Не удалось распарсить VLESS ссылки"}, 400)
|
||||
return
|
||||
|
||||
# Create a mock config with parsed outbounds
|
||||
config = {"outbounds": outbounds}
|
||||
|
||||
# Extract outbound servers
|
||||
outbounds = config.get("outbounds", [])
|
||||
servers = []
|
||||
|
||||
for outbound in outbounds:
|
||||
if outbound.get("type") in ["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]:
|
||||
servers.append({
|
||||
"tag": outbound.get("tag", "unknown"),
|
||||
"type": outbound.get("type"),
|
||||
"server": outbound.get("server", "unknown"),
|
||||
"server_port": outbound.get("server_port", 443)
|
||||
})
|
||||
|
||||
if not servers:
|
||||
self.send_json({"success": False, "error": "Серверы не найдены в подписке"}, 400)
|
||||
return
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"servers": servers,
|
||||
"config": config,
|
||||
"userInfo": user_info
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON в ответе"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
def apply_subscription(self):
|
||||
"""Apply config from subscription with selected server"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
|
||||
config = data.get("config")
|
||||
selected_tag = data.get("selectedServer")
|
||||
sub_url = data.get("subUrl") # URL подписки для сохранения
|
||||
user_info = data.get("userInfo")
|
||||
|
||||
if not config:
|
||||
self.send_json({"success": False, "error": "Конфигурация не указана"}, 400)
|
||||
return
|
||||
|
||||
if not selected_tag:
|
||||
self.send_json({"success": False, "error": "Сервер не выбран"}, 400)
|
||||
return
|
||||
|
||||
# Modify config to use only selected server
|
||||
outbounds = config.get("outbounds", [])
|
||||
new_outbounds = []
|
||||
selected_outbound = None
|
||||
|
||||
for outbound in outbounds:
|
||||
if outbound.get("tag") == selected_tag:
|
||||
selected_outbound = outbound
|
||||
elif outbound.get("type") in ["direct", "block", "dns"]:
|
||||
new_outbounds.append(outbound)
|
||||
elif outbound.get("type") == "selector":
|
||||
# Skip selector, we'll add selected server directly
|
||||
pass
|
||||
|
||||
if not selected_outbound:
|
||||
self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400)
|
||||
return
|
||||
|
||||
# Add selected server as main outbound
|
||||
new_outbounds.insert(0, selected_outbound)
|
||||
|
||||
# Update route - remove incompatible fields and set only final
|
||||
routes = {
|
||||
"final": selected_tag,
|
||||
"auto_detect_interface": True
|
||||
}
|
||||
|
||||
# Simplify DNS configuration to match client.json format
|
||||
config["dns"] = {
|
||||
"independent_cache": True
|
||||
}
|
||||
|
||||
# Remove platform-specific and experimental fields from root config
|
||||
config.pop("platform", None)
|
||||
config.pop("experimental", None)
|
||||
|
||||
# Replace TUN inbounds with mixed proxy
|
||||
config["inbounds"] = [
|
||||
{
|
||||
"tag": "mixed-in",
|
||||
"type": "mixed",
|
||||
"sniff": True,
|
||||
"users": [],
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": PROXY_PORT,
|
||||
"set_system_proxy": False
|
||||
}
|
||||
]
|
||||
|
||||
config["outbounds"] = new_outbounds
|
||||
config["route"] = routes
|
||||
|
||||
# Ensure data directory exists
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write config file
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
# Save subscription URL for persistence
|
||||
if sub_url:
|
||||
save_subscription(sub_url, selected_tag, user_info)
|
||||
|
||||
# Trigger reload via internal control port
|
||||
try:
|
||||
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": f"Сервер '{selected_tag}' успешно применён!"
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the web server"""
|
||||
# Use ThreadingTCPServer for concurrent requests
|
||||
with ThreadingHTTPServer(("", PORT), ProxyControlHandler) as httpd:
|
||||
print(f"[WebUI] Server started on port {PORT}")
|
||||
print(f"[WebUI] Open http://localhost:{PORT} in your browser")
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user