Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
PORT=3456
|
||||||
|
APP_MODE=gateway
|
||||||
|
CLIENT_UI_PORT=3456
|
||||||
|
CLIENT_PROXY_PORT=8080
|
||||||
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
|
SINGBOX_VERSION=1.12.13
|
||||||
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
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
|
||||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,2 +1,24 @@
|
|||||||
data
|
# Local archive with the previous implementation and runtime secrets
|
||||||
_legacy
|
_archive/
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
data/
|
||||||
|
.vpn-proxy/
|
||||||
|
|
||||||
|
# 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*
|
||||||
616
README.md
616
README.md
@@ -1,353 +1,423 @@
|
|||||||
# 🌐 VPN Proxy — Домашний VPN в одной программе
|
# VPN Proxy
|
||||||
|
|
||||||
> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях.
|
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||||
|
|
||||||
---
|
## macOS: локальный Docker-клиент
|
||||||
|
|
||||||
## 📖 Что это такое?
|
Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.
|
||||||
|
|
||||||
Это набор инструментов, который позволяет:
|
|
||||||
|
|
||||||
1. **Запустить VPN-прокси** на вашем компьютере
|
|
||||||
2. **Управлять через удобное меню** — всё настраивается автоматически
|
|
||||||
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
|
|
||||||
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
|
|
||||||
|
|
||||||
### 🎯 Для кого это?
|
|
||||||
|
|
||||||
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
|
|
||||||
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
|
|
||||||
- Геймеры, которым нужно запустить игры или Discord через VPN
|
|
||||||
- Люди, которые получили VLESS ссылку от VPN-провайдера
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 Как это работает?
|
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN.
|
||||||
|
|
||||||
|
Ручной запуск из 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 **не подойдёт!**
|
Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
|
||||||
|
|
||||||
#### Проверьте вашу версию
|
|
||||||
|
|
||||||
Откройте любой 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 — синий фон.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### ✅ 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`.
|
| Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
|
||||||
|
| Перехват трафика | iptables TProxy + iproute2 policy routing |
|
||||||
1. Откройте **PowerShell 7** от имени **Администратора**
|
| Bypass-кэш | опциональный ipset `hash:ip` с TTL |
|
||||||
2. Скопируйте и вставьте команду:
|
| VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
|
||||||
|
| API-сервер | Node.js 18, plain `http` (без фреймворков) |
|
||||||
```powershell
|
| Веб-интерфейс | React 18 + Vite 7, SPA |
|
||||||
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` в ссылке, или используйте ручную установку ниже.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 📦 Ручная установка (если авто-установка не работает)
|
## Как работает прозрачное проксирование
|
||||||
|
|
||||||
Если вы предпочитаете всё делать сами:
|
### 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
|
# Цепочка iptables (порядок правил — критичен)
|
||||||
# 1. Создаем папку и переходим
|
iptables -t mangle -N VPN_PROXY_TPROXY
|
||||||
New-Item -ItemType Directory -Force -Path "C:\Tools" | Out-Null
|
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
|
||||||
cd C:\Tools
|
-m mark --mark 1 → RETURN # уже помеченные пакеты
|
||||||
|
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
|
||||||
# 2. Клонируем или скачиваем архив
|
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
||||||
git clone https://git.dokops.ru/dokril/vpn-proxy
|
-p tcp → TPROXY :7895 mark 1
|
||||||
|
-p udp → TPROXY :7895 mark 1
|
||||||
# (Или скачайте ZIP вручную и распакуйте в C:\Tools\vpn-proxy)
|
iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Шаг 2: Запустите
|
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||||
|
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
||||||
|
|
||||||
```powershell
|
### 2. Маршрутизация внутри sing-box
|
||||||
cd C:\Tools\vpn-proxy
|
|
||||||
.\manage.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 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. Bypass Mode (весь трафик напрямую)
|
||||||
[3] 🔄 Обновить статус
|
|
||||||
[U] ❌ Удалить всё (Uninstall)
|
|
||||||
[q] Выход
|
|
||||||
|
|
||||||
👉 Ваш выбор: 1
|
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 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` (системная служба)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Проверка работы
|
## 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) [РАБОТАЕТ]
|
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
|
||||||
Основной способ. Поддерживает UDP и игры.
|
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
|
||||||
|
DIRECT_BYPASS_TTL=3600 # TTL в секундах
|
||||||
📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ
|
|
||||||
─────────────────────────────
|
|
||||||
Локально: 127.0.0.1:1080
|
|
||||||
Из сети:
|
|
||||||
192.168.1.100:1080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Проверка через терминал
|
## Профили устройств
|
||||||
|
|
||||||
```powershell
|
Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:
|
||||||
# Без прокси — покажет ваш домашний 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`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"http.proxy": "http://127.0.0.1:1080",
|
"defaultTransparentMode": "vpn",
|
||||||
"http.proxyStrictSSL": true
|
"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 |
|
||||||
|
|
||||||
В настройках прокси вашего браузера укажите:
|
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
|
||||||
|
|
||||||
- **Тип**: 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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Управление
|
## Кастомные правила маршрутизации
|
||||||
|
|
||||||
При повторном запуске `.\manage.ps1` скрипт покажет меню управления:
|
Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
|
||||||
|
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.
|
||||||
|
|
||||||
| Действие | Как сделать |
|
| Поле | Тип | Описание |
|
||||||
|----------|-------------|
|
| ---------------- | ---------------------------- | ------------------------------------------- |
|
||||||
| Посмотреть статус | Запустить `.\manage.ps1` |
|
| `name` | string | Название правила |
|
||||||
| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" |
|
| `enabled` | bool | Вкл/выкл |
|
||||||
| Перезапустить | Пункт [1] → "Перезапустить" |
|
| `outbound` | `direct` \| `vpn` \| `block` | Куда отправить трафик |
|
||||||
| Остановить | Пункт [1] → "Остановить" |
|
| `domains` | string[] | Точные домены (`example.com`) |
|
||||||
| Полностью удалить | Пункт [U] |
|
| `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-адрес в меню (раздел "Из сети:")
|
```json
|
||||||
2. На другом устройстве настройте прокси: `IP_ВАШЕГО_ПК:1080`
|
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
|
||||||
|
|
||||||
Например: `192.168.1.100:1080`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Часто задаваемые вопросы
|
|
||||||
|
|
||||||
### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено"
|
|
||||||
|
|
||||||
**Решение:** Включите выполнение скриптов:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибка при запуске — непонятные символы или синтаксис
|
sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.
|
||||||
|
|
||||||
**Причина:** Вы используете старый 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Продвинутые варианты
|
## Подписки
|
||||||
|
|
||||||
### Docker с веб-интерфейсом
|
Поддерживаемые форматы:
|
||||||
|
|
||||||
Если вы предпочитаете управлять через браузер с красивым интерфейсом:
|
- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
|
||||||
|
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
|
||||||
|
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)
|
||||||
|
|
||||||
> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**!
|
После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.
|
||||||
> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню).
|
|
||||||
|
|
||||||
📖 **[Инструкция по Docker](docs/DOCKER.md)**
|
Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.
|
||||||
|
|
||||||
### Установка на удалённый сервер (VPS)
|
|
||||||
|
|
||||||
Если вы хотите развернуть прокси на своём сервере в другой стране:
|
|
||||||
|
|
||||||
📖 **[Инструкция по установке на сервер](docs/SERVER.md)**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Словарь терминов
|
## Просмотр трафика
|
||||||
|
|
||||||
| Термин | Объяснение |
|
Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).
|
||||||
|--------|------------|
|
|
||||||
| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени |
|
### Парсинг логов sing-box
|
||||||
| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером |
|
|
||||||
| **VLESS** | Современный протокол VPN-соединения |
|
Node.js читает stderr sing-box и извлекает трафик двумя шагами:
|
||||||
| **sing-box** | Программа-клиент для подключения к VPN |
|
|
||||||
| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) |
|
```
|
||||||
| **Порт** | "Номер двери" для сетевых соединений |
|
[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**
|
1. private IP → direct
|
||||||
2. Запустите от имени **Администратора**
|
2. global custom rules
|
||||||
3. Проверьте статус в главном меню
|
3. geoip-ru / geosite-category-ru → direct
|
||||||
4. Попробуйте переустановить: пункт [U], затем пункт [1]
|
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` |
|
||||||
|
| `CLIENT_PROXY_PORT` | `8080` | Host-порт proxy для `docker-compose.client.yml` |
|
||||||
|
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||||
|
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||||
|
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||||
|
| `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` | Полное состояние системы |
|
||||||
|
| `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 и порты.
|
||||||
|
|||||||
36
docker-compose.client.yml
Normal file
36
docker-compose.client.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
vpn-proxy-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.client
|
||||||
|
args:
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
|
container_name: vpn-proxy-client
|
||||||
|
environment:
|
||||||
|
APP_MODE: client
|
||||||
|
PORT: ${PORT:-3456}
|
||||||
|
PROXY_PORT: ${PROXY_PORT:-8080}
|
||||||
|
PROXY_BIND_IP: 0.0.0.0
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||||
|
- "127.0.0.1:${CLIENT_PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-client-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-client-data:
|
||||||
|
sing-box-client-cache:
|
||||||
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:
|
services:
|
||||||
sing-proxy:
|
vpn-proxy-gateway:
|
||||||
container_name: sing-proxy
|
image: ${GATEWAY_IMAGE}
|
||||||
build:
|
container_name: vpn-proxy-gateway
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile.singbox
|
|
||||||
|
|
||||||
# HOST MODE — контейнер использует сеть хоста напрямую
|
|
||||||
# Это решает проблему UDP ASSOCIATE для SOCKS5
|
|
||||||
# ВАЖНО: работает только на Linux, не на Windows/macOS!
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
# Порт веб-интерфейса (по умолчанию 3456)
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
- PORT=${PORT:-3456}
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
# Порт прокси HTTP/SOCKS5 (по умолчанию 8080)
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
- PROXY_PORT=${PROXY_PORT:-8080}
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256m
|
|
||||||
|
|
||||||
# Порты при network_mode: host не нужно пробрасывать,
|
volumes:
|
||||||
# они автоматически доступны на хосте:
|
vpn-proxy-data:
|
||||||
# - 3456: Веб-интерфейс (PORT)
|
sing-box-cache:
|
||||||
# - 8080: SOCKS5/HTTP прокси (PROXY_PORT)
|
|
||||||
|
|||||||
@@ -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.
|
||||||
48
docs/superpowers/specs/2026-05-19-macos-client-design.md
Normal file
48
docs/superpowers/specs/2026-05-19-macos-client-design.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# macOS Docker Client Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a simple macOS-friendly Docker client that behaves like the previous local proxy product: the user runs one container, opens a web UI, loads a subscription, chooses a server, and points macOS apps at `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
## Product Shape
|
||||||
|
|
||||||
|
The client is not a transparent gateway. It must not require router changes, host networking, `NET_ADMIN`, iptables, ipset, or TProxy. The first-screen UI should explain the current proxy state, active server, and exact local proxy addresses. Gateway-only controls remain available only when the app runs in gateway mode.
|
||||||
|
|
||||||
|
## Runtime Architecture
|
||||||
|
|
||||||
|
`APP_MODE=client` switches the config generator to proxy-only sing-box config:
|
||||||
|
|
||||||
|
- one `mixed` inbound on `PROXY_PORT`;
|
||||||
|
- no `tproxy` inbound;
|
||||||
|
- custom routing rules still apply before fallback;
|
||||||
|
- `proxyDefaultMode` controls the mixed proxy fallback and defaults to VPN;
|
||||||
|
- generated configs still pass `sing-box check` before restart.
|
||||||
|
|
||||||
|
The client Docker image builds the React frontend inside Docker so macOS installation does not require local Node.js. Docker publishes only loopback ports:
|
||||||
|
|
||||||
|
- `127.0.0.1:3456` for the UI;
|
||||||
|
- `127.0.0.1:8080` for HTTP/SOCKS proxy.
|
||||||
|
|
||||||
|
## Installer
|
||||||
|
|
||||||
|
The macOS installer is a curl-friendly shell script. It checks macOS, Docker, Docker Compose, and Git, clones or updates the repository under `~/.vpn-proxy-client`, then runs the client compose file with `--build`. It prints the UI URL, proxy URLs, and optional `networksetup` commands, but does not change system proxy settings automatically.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Client mode gets a user-facing overview based on the old workflow:
|
||||||
|
|
||||||
|
- status: ready, stopped, not configured, applying, error;
|
||||||
|
- active server and traffic quota;
|
||||||
|
- copyable HTTP and SOCKS5 proxy URLs;
|
||||||
|
- short macOS setup commands;
|
||||||
|
- primary actions: load subscription, choose server, restart, stop.
|
||||||
|
|
||||||
|
Gateway terminology such as TProxy, devices, router, transparent fallback, and direct bypass cache is hidden in client mode.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use `node:test` for server config behavior, then run:
|
||||||
|
|
||||||
|
- `npm test`;
|
||||||
|
- `npm run build`;
|
||||||
|
- `docker compose -f docker-compose.client.yml config`.
|
||||||
21
entrypoint.client.sh
Executable file
21
entrypoint.client.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${PORT:-3456}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
|
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||||
|
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||||
|
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[client-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
|
||||||
|
|
||||||
|
export APP_MODE=client
|
||||||
|
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
||||||
|
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
|
||||||
|
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||||
|
exec node /app/src/server/index.js
|
||||||
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"
|
||||||
78
scripts/install-macos-client.sh
Executable file
78
scripts/install-macos-client.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||||
|
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
||||||
|
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||||
|
COMPOSE_FILE="docker-compose.client.yml"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[vpn-proxy-client] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[vpn-proxy-client] error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
need() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||||
|
die "this installer is intended for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need git
|
||||||
|
need docker
|
||||||
|
|
||||||
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
log "updating $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||||
|
else
|
||||||
|
log "cloning $REPO_URL#$BRANCH to $INSTALL_DIR"
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env && -f .env.example ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "building and starting Docker client"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
VPN Proxy Client is running.
|
||||||
|
|
||||||
|
UI:
|
||||||
|
http://127.0.0.1:3456
|
||||||
|
|
||||||
|
Proxy:
|
||||||
|
HTTP/SOCKS5 127.0.0.1:8080
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
|
docker compose -f docker-compose.client.yml logs -f
|
||||||
|
docker compose -f docker-compose.client.yml restart
|
||||||
|
docker compose -f docker-compose.client.yml down
|
||||||
|
|
||||||
|
Optional macOS system proxy example:
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 8080
|
||||||
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8080
|
||||||
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8080
|
||||||
|
|
||||||
|
Disable later:
|
||||||
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
|
networksetup -setsecurewebproxystate Wi-Fi off
|
||||||
|
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||||
|
|
||||||
|
EOF
|
||||||
@@ -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
|
|
||||||
43
src/server/clientSettings.js
Normal file
43
src/server/clientSettings.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { settings } from "./config.js";
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT_SETTINGS = {
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClientSettings(input = {}) {
|
||||||
|
return {
|
||||||
|
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readClientSettings() {
|
||||||
|
return normalizeClientSettings({
|
||||||
|
...DEFAULT_CLIENT_SETTINGS,
|
||||||
|
...readJson(settings.clientSettingsPath, {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeClientSettings(input) {
|
||||||
|
const normalized = normalizeClientSettings({
|
||||||
|
...readClientSettings(),
|
||||||
|
...(input && typeof input === "object" ? input : {}),
|
||||||
|
});
|
||||||
|
writeJson(settings.clientSettingsPath, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
28
src/server/config.js
Normal file
28
src/server/config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||||
|
port: Number(process.env.PORT || 3456),
|
||||||
|
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||||
|
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||||
|
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"),
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
1487
src/server/index.js
Normal file
1487
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;
|
||||||
|
}
|
||||||
348
src/server/singbox.js
Normal file
348
src/server/singbox.js
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayConfig(
|
||||||
|
subscriptionConfig,
|
||||||
|
selectedTag,
|
||||||
|
{ bypassAll = false } = {},
|
||||||
|
) {
|
||||||
|
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
||||||
|
if (!selectedOutbound) {
|
||||||
|
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpnOutbound = clone(selectedOutbound);
|
||||||
|
if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
|
||||||
|
if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
|
||||||
|
vpnOutbound.packet_encoding = "xudp";
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRuleSets = readCustomRuleSets();
|
||||||
|
const clientMode = settings.appMode === "client";
|
||||||
|
const clientSettings = clientMode ? readClientSettings() : null;
|
||||||
|
const clientOutbound = clientSettings?.homeBypassEnabled
|
||||||
|
? "direct"
|
||||||
|
: vpnOutbound.tag;
|
||||||
|
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: settings.proxyPort,
|
||||||
|
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: [
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
565
src/web/App.jsx
Normal file
565
src/web/App.jsx
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
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 });
|
||||||
|
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 });
|
||||||
|
await loadState();
|
||||||
|
}, { quiet: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 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}
|
||||||
|
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 />);
|
||||||
122
src/web/api.js
Normal file
122
src/web/api.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
src/web/components/ClientOverviewPage.jsx
Normal file
305
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { flagFor } from '../utils/country.js';
|
||||||
|
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||||
|
|
||||||
|
function CopyField({ label, value }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="copy-field">
|
||||||
|
<div>
|
||||||
|
<small className="muted">{label}</small>
|
||||||
|
<div className="text-mono">{value}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary sm" onClick={copy}>
|
||||||
|
{copied ? 'Скопировано' : 'Копировать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientHero({ state, status, activeServer }) {
|
||||||
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
|
const cfg = {
|
||||||
|
running: {
|
||||||
|
title: 'Локальный proxy работает',
|
||||||
|
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
|
||||||
|
badge: 'Готов',
|
||||||
|
kind: 'success',
|
||||||
|
},
|
||||||
|
applying: {
|
||||||
|
title: 'Применяем сервер',
|
||||||
|
hint: 'sing-box перезапускается',
|
||||||
|
badge: 'Применяем',
|
||||||
|
kind: 'warning',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: 'Нужна проверка',
|
||||||
|
hint: 'Откройте логи и config',
|
||||||
|
badge: 'Ошибка',
|
||||||
|
kind: 'danger',
|
||||||
|
},
|
||||||
|
stopped: {
|
||||||
|
title: 'Proxy остановлен',
|
||||||
|
hint: 'Конфиг есть, sing-box не запущен',
|
||||||
|
badge: 'Остановлен',
|
||||||
|
kind: 'warning',
|
||||||
|
},
|
||||||
|
no_config: {
|
||||||
|
title: 'Proxy ещё не настроен',
|
||||||
|
hint: 'Загрузите подписку и выберите сервер',
|
||||||
|
badge: 'Не настроен',
|
||||||
|
kind: 'neutral',
|
||||||
|
},
|
||||||
|
}[status] || {};
|
||||||
|
const view = homeBypass
|
||||||
|
? {
|
||||||
|
...cfg,
|
||||||
|
title: 'Домашний режим: VPN выключен',
|
||||||
|
hint: 'Локальный proxy работает напрямую',
|
||||||
|
badge: 'Напрямую',
|
||||||
|
kind: 'info',
|
||||||
|
}
|
||||||
|
: cfg;
|
||||||
|
|
||||||
|
const userInfo = state?.userInfo;
|
||||||
|
const traffic = userInfo
|
||||||
|
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||||
|
: 'нет данных';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="client-hero">
|
||||||
|
<div className="client-hero-main">
|
||||||
|
<span className={`badge ${view.kind}`}>{view.badge}</span>
|
||||||
|
<h1>{view.title}</h1>
|
||||||
|
<p>{view.hint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="client-hero-meta">
|
||||||
|
<div>
|
||||||
|
<small className="muted">Активный сервер</small>
|
||||||
|
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="muted">Трафик</small>
|
||||||
|
<strong>{traffic}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="muted">Применено</small>
|
||||||
|
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientSetup({
|
||||||
|
state,
|
||||||
|
servers,
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
pendingTag,
|
||||||
|
setPendingTag,
|
||||||
|
busy,
|
||||||
|
onFetchSubscription,
|
||||||
|
onApply,
|
||||||
|
}) {
|
||||||
|
const selected = pendingTag || state?.selectedTag || '';
|
||||||
|
const canApply = selected && selected !== state?.selectedTag;
|
||||||
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card client-setup">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Подключение</h2>
|
||||||
|
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">URL подписки или VLESS-ссылка</label>
|
||||||
|
<div className="subscription-input">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="https://… или vless://…"
|
||||||
|
value={subscriptionUrl}
|
||||||
|
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
||||||
|
Загрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">VPN-сервер</label>
|
||||||
|
<div className="subscription-input">
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={selected}
|
||||||
|
disabled={!servers.length}
|
||||||
|
onChange={(e) => setPendingTag(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Выберите сервер</option>
|
||||||
|
{servers.map((server) => (
|
||||||
|
<option key={server.tag} value={server.tag}>
|
||||||
|
{flagFor(server)} {server.tag}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="field-hint">
|
||||||
|
{homeBypass
|
||||||
|
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
||||||
|
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProxyCard({ state }) {
|
||||||
|
const port = state?.proxyPort || 8080;
|
||||||
|
const urls = useMemo(() => ({
|
||||||
|
http: `http://127.0.0.1:${port}`,
|
||||||
|
socks: `socks5://127.0.0.1:${port}`,
|
||||||
|
}), [port]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Локальный proxy</h2>
|
||||||
|
<span className="badge info">127.0.0.1:{port}</span>
|
||||||
|
</div>
|
||||||
|
<div className="copy-stack">
|
||||||
|
<CopyField label="HTTP / HTTPS" value={urls.http} />
|
||||||
|
<CopyField label="SOCKS5" value={urls.socks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeBypassCard({ settings, busy, onSave }) {
|
||||||
|
const enabled = Boolean(settings?.homeBypassEnabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Домашний режим</h2>
|
||||||
|
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
|
||||||
|
{enabled ? 'Напрямую' : 'Через VPN'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется.
|
||||||
|
</p>
|
||||||
|
<label className="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>Я дома</strong>
|
||||||
|
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientFlow({ state, activeServer }) {
|
||||||
|
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||||
|
const steps = [
|
||||||
|
{ label: 'Mac', value: 'приложения' },
|
||||||
|
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
||||||
|
{ label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' },
|
||||||
|
{ label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header"><h2>Цепочка подключения</h2></div>
|
||||||
|
<div className="client-flow">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<React.Fragment key={step.label}>
|
||||||
|
<div className="flow-node">
|
||||||
|
<small>{step.label}</small>
|
||||||
|
<strong>{step.value}</strong>
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && <span className="flow-arrow">→</span>}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientActions({ state, busy, onRestart, onStop }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header"><h2>Управление</h2></div>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
|
||||||
|
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientOverviewPage({
|
||||||
|
state,
|
||||||
|
status,
|
||||||
|
activeServer,
|
||||||
|
busy,
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
servers,
|
||||||
|
pendingTag,
|
||||||
|
setPendingTag,
|
||||||
|
clientSettings,
|
||||||
|
onSaveClientSettings,
|
||||||
|
onFetchSubscription,
|
||||||
|
onApply,
|
||||||
|
onRestart,
|
||||||
|
onStop,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-stack">
|
||||||
|
<ClientHero state={state} status={status} activeServer={activeServer} />
|
||||||
|
<ClientSetup
|
||||||
|
state={state}
|
||||||
|
servers={servers}
|
||||||
|
subscriptionUrl={subscriptionUrl}
|
||||||
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
|
pendingTag={pendingTag}
|
||||||
|
setPendingTag={setPendingTag}
|
||||||
|
busy={busy}
|
||||||
|
onFetchSubscription={onFetchSubscription}
|
||||||
|
onApply={onApply}
|
||||||
|
/>
|
||||||
|
<div className="grid-2">
|
||||||
|
<ProxyCard state={state} />
|
||||||
|
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
|
||||||
|
</div>
|
||||||
|
<div className="grid-2">
|
||||||
|
<ClientActions
|
||||||
|
state={state}
|
||||||
|
busy={busy}
|
||||||
|
onRestart={onRestart}
|
||||||
|
onStop={onStop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ClientFlow state={state} activeServer={activeServer} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
974
src/web/styles.css
Normal file
974
src/web/styles.css
Normal file
@@ -0,0 +1,974 @@
|
|||||||
|
/* Системные шрифты — без загрузки из интернета */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, sans-serif;
|
||||||
|
--font-head: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif;
|
||||||
|
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Cascadia Code', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--bg: #06110d;
|
||||||
|
--surface: #0b1b14;
|
||||||
|
--surface-2: #10251b;
|
||||||
|
--surface-3: #163224;
|
||||||
|
--border: #214734;
|
||||||
|
--border-strong: #2c5d44;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #effff5;
|
||||||
|
--muted: #9fbaaa;
|
||||||
|
--subtle: #6f8c7c;
|
||||||
|
|
||||||
|
/* Roles */
|
||||||
|
--success: #6dff9d;
|
||||||
|
--success-dim: rgba(109, 255, 157, 0.14);
|
||||||
|
--warning: #ffd166;
|
||||||
|
--warning-dim: rgba(255, 209, 102, 0.14);
|
||||||
|
--danger: #ff5c5c;
|
||||||
|
--danger-dim: rgba(255, 92, 92, 0.14);
|
||||||
|
--accent: #b7ff63;
|
||||||
|
--accent-dim: rgba(183, 255, 99, 0.14);
|
||||||
|
--info: #8ed4ff;
|
||||||
|
--info-dim: rgba(142, 212, 255, 0.14);
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--radius-card: 20px;
|
||||||
|
--radius-input: 12px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-card: 0 12px 36px rgba(0, 0, 0, 0.32);
|
||||||
|
--shadow-modal: 0 32px 80px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--topbar-h: 64px;
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
--status-w: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
h3 { font-size: 15px; color: var(--text); }
|
||||||
|
h4 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
|
||||||
|
p { margin: 0; }
|
||||||
|
small { font-size: 12px; color: var(--muted); }
|
||||||
|
code, .mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Layout ============ */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: var(--topbar-h) 1fr;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 0%, rgba(109, 255, 157, 0.05), transparent 32rem),
|
||||||
|
radial-gradient(circle at 92% 100%, rgba(142, 212, 255, 0.04), transparent 28rem),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.app-body.client-mode {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: var(--space-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.app-body { grid-template-columns: var(--sidebar-w) 1fr; }
|
||||||
|
.status-pane { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-body { grid-template-columns: 1fr; }
|
||||||
|
.app-body.client-mode { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.app-main { padding: var(--space-4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Topbar ============ */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: var(--topbar-h);
|
||||||
|
}
|
||||||
|
.topbar-brand {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.topbar-brand .logo-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px var(--accent);
|
||||||
|
}
|
||||||
|
.topbar-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-status .status-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-status .status-text strong { font-weight: 600; font-size: 13px; }
|
||||||
|
.topbar-status .status-text small { font-size: 11px; color: var(--subtle); }
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Sidebar ============ */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: var(--space-4) var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sidebar-item.active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.sidebar-item .ico { width: 18px; opacity: 0.85; flex: 0 0 18px; }
|
||||||
|
.sidebar-item .badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.sidebar-item .badge.warn { background: var(--warning-dim); color: var(--warning); }
|
||||||
|
.sidebar-item .badge.danger { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
|
||||||
|
/* ============ Status pane ============ */
|
||||||
|
|
||||||
|
.status-pane {
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Cards & panels ============ */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--space-5);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.card.compact { padding: var(--space-4); }
|
||||||
|
.card.flat { box-shadow: none; background: var(--surface-2); }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.card-header.no-margin { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.section-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Buttons ============ */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn.lg { padding: 12px 18px; font-size: 14px; }
|
||||||
|
.btn.sm { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.btn.block { width: 100%; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #061608;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) { background: var(--danger-dim); }
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.btn-link:hover:not(:disabled) { text-decoration: underline; }
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Inputs ============ */
|
||||||
|
|
||||||
|
.input, .select, .textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: 9px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s, box-shadow 0.12s;
|
||||||
|
}
|
||||||
|
.input:focus, .select:focus, .textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
|
}
|
||||||
|
.textarea { min-height: 80px; resize: vertical; font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.field-error { color: var(--danger); font-size: 11px; }
|
||||||
|
.field-hint { color: var(--subtle); font-size: 11px; }
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.checkbox input { width: 14px; height: 14px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ============ Badges & dots ============ */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.success { background: var(--success-dim); color: var(--success); border-color: rgba(109, 255, 157, 0.3); }
|
||||||
|
.badge.warning { background: var(--warning-dim); color: var(--warning); border-color: rgba(255, 209, 102, 0.3); }
|
||||||
|
.badge.danger { background: var(--danger-dim); color: var(--danger); border-color: rgba(255, 92, 92, 0.3); }
|
||||||
|
.badge.info { background: var(--info-dim); color: var(--info); border-color: rgba(142, 212, 255, 0.3); }
|
||||||
|
.badge.neutral { background: var(--surface-3); color: var(--muted); }
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--subtle);
|
||||||
|
flex: 0 0 8px;
|
||||||
|
}
|
||||||
|
.dot.success { background: var(--success); box-shadow: 0 0 8px var(--success); }
|
||||||
|
.dot.warning { background: var(--warning); box-shadow: 0 0 8px var(--warning); }
|
||||||
|
.dot.danger { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
|
||||||
|
.dot.info { background: var(--info); box-shadow: 0 0 8px var(--info); }
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Lists & tables ============ */
|
||||||
|
|
||||||
|
.kv-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.kv-list .row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.kv-list .row:last-child { border-bottom: 0; }
|
||||||
|
.kv-list .key { color: var(--muted); }
|
||||||
|
.kv-list .val { color: var(--text); font-weight: 500; }
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.table th, .table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.table th {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.table tbody tr { transition: background 0.1s; }
|
||||||
|
.table tbody tr:hover { background: var(--surface-2); }
|
||||||
|
.table tbody tr.active { background: var(--accent-dim); }
|
||||||
|
.table .row-actions { display: flex; gap: var(--space-2); justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* ============ Tag input (chips) ============ */
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.chip.error { border-color: var(--danger); color: var(--danger); }
|
||||||
|
.chip button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.chip button:hover { color: var(--danger); }
|
||||||
|
.chips .chip-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Sticky action bar ============ */
|
||||||
|
|
||||||
|
.sticky-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
margin: var(--space-6) calc(-1 * var(--space-6)) calc(-1 * var(--space-6));
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
z-index: 5;
|
||||||
|
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Drawer & modal ============ */
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 8, 5, 0.65);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal.lg { width: min(960px, 100%); }
|
||||||
|
.modal-head, .modal-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-foot { border-top: 1px solid var(--border); border-bottom: 0; }
|
||||||
|
.modal-body { padding: var(--space-5); overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
.drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 8, 5, 0.55);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
.drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; right: 0; bottom: 0;
|
||||||
|
width: min(540px, 100%);
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
z-index: 91;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.drawer-head, .drawer-foot {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.drawer-foot { border-top: 1px solid var(--border); border-bottom: 0; }
|
||||||
|
.drawer-body { padding: var(--space-5); overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
|
|
||||||
|
/* ============ Toasts ============ */
|
||||||
|
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--topbar-h) + 12px);
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 200;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toast.success { border-color: rgba(109, 255, 157, 0.3); }
|
||||||
|
.toast.danger { border-color: rgba(255, 92, 92, 0.3); }
|
||||||
|
.toast.warning { border-color: rgba(255, 209, 102, 0.3); }
|
||||||
|
.toast .body { flex: 1; }
|
||||||
|
.toast .body small { display: block; color: var(--muted); margin-top: 2px; }
|
||||||
|
.toast button { background: none; border: none; color: var(--muted); cursor: pointer; }
|
||||||
|
|
||||||
|
/* ============ Logs ============ */
|
||||||
|
|
||||||
|
.logs-stream {
|
||||||
|
background: #02080a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
.log-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 60px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log-line:hover { background: var(--surface-2); }
|
||||||
|
.log-line .log-time { color: var(--subtle); }
|
||||||
|
.log-line .log-level { font-size: 10px; text-transform: uppercase; padding-top: 2px; font-weight: 600; }
|
||||||
|
.log-line.info .log-level { color: var(--info); }
|
||||||
|
.log-line.warning .log-level { color: var(--warning); }
|
||||||
|
.log-line.error .log-level { color: var(--danger); }
|
||||||
|
.log-line.debug .log-level { color: var(--subtle); }
|
||||||
|
.log-line .log-text { color: var(--text); }
|
||||||
|
|
||||||
|
.log-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 60px 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.log-group .repeat { color: var(--warning); font-size: 11px; }
|
||||||
|
|
||||||
|
/* ============ Misc ============ */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty-state h3 { margin-bottom: var(--space-2); color: var(--text); }
|
||||||
|
|
||||||
|
.flex { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
.flex-col { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-1 { flex: 1; min-width: 0; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.text-success { color: var(--success); }
|
||||||
|
.text-warning { color: var(--warning); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
.text-mono { font-family: var(--font-mono); }
|
||||||
|
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-view {
|
||||||
|
background: #02080a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* drag handle for rules sortable */
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--subtle);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.drag-handle:hover { color: var(--text); background: var(--surface-2); }
|
||||||
|
.drag-handle:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.rule-row.disabled { opacity: 0.5; }
|
||||||
|
.rule-row.invalid td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
|
||||||
|
.conflict-banner {
|
||||||
|
background: var(--warning-dim);
|
||||||
|
border: 1px solid rgba(255, 209, 102, 0.3);
|
||||||
|
color: var(--warning);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.conflict-banner.danger { background: var(--danger-dim); border-color: rgba(255, 92, 92, 0.3); color: var(--danger); }
|
||||||
|
|
||||||
|
.template-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.template-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.template-card h4 { color: var(--text); text-transform: none; letter-spacing: 0; font-size: 13px; }
|
||||||
|
.template-card small { color: var(--subtle); font-size: 11px; line-height: 1.4; }
|
||||||
|
|
||||||
|
.events-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.event-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px 60px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.event-row:hover { background: var(--surface-2); }
|
||||||
|
.event-row .event-time { color: var(--subtle); font-family: var(--font-mono); font-size: 11px; }
|
||||||
|
|
||||||
|
.route-result {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
padding: var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filter-bar .input, .filter-bar .select { width: auto; min-width: 140px; }
|
||||||
|
|
||||||
|
.subscription-input {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.subscription-input .input { flex: 1; }
|
||||||
|
|
||||||
|
/* ============ Client overview ============ */
|
||||||
|
|
||||||
|
.client-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--space-6);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.client-mode .app-main {
|
||||||
|
max-width: 1120px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.client-hero-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.client-hero-main h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.client-hero-main p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.client-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.client-hero-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
.client-hero-meta > div {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.client-hero-meta strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.copy-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.client-setup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.switch-row {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.switch-row span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.switch-row input {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.copy-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
}
|
||||||
|
.copy-field .text-mono {
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.client-flow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.flow-node {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
}
|
||||||
|
.flow-node strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.flow-arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.client-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.client-flow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.flow-arrow {
|
||||||
|
justify-content: center;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.copy-field {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For drawer rule editor */
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) { .field-row { grid-template-columns: 1fr; } }
|
||||||
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",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
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);
|
||||||
|
}
|
||||||
61
test/server/singbox-client-mode.test.js
Normal file
61
test/server/singbox-client-mode.test.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
process.env.APP_MODE = "client";
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-proxy-test-"));
|
||||||
|
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
|
||||||
|
|
||||||
|
const { buildGatewayConfig } = await import(
|
||||||
|
`../../src/server/singbox.js?client-mode=${Date.now()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionConfig = {
|
||||||
|
outbounds: [
|
||||||
|
{
|
||||||
|
type: "vless",
|
||||||
|
tag: "test-vpn",
|
||||||
|
server: "vpn.example.test",
|
||||||
|
server_port: 443,
|
||||||
|
uuid: "00000000-0000-4000-8000-000000000000",
|
||||||
|
tls: { enabled: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customRules: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("client mode exposes only the local mixed proxy inbound", () => {
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
config.inbounds.map((inbound) => inbound.tag),
|
||||||
|
["mixed-in"],
|
||||||
|
);
|
||||||
|
assert.equal(config.inbounds[0].type, "mixed");
|
||||||
|
assert.equal(config.inbounds[0].listen_port, 8080);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||||
|
|
||||||
|
assert.deepEqual(config.route.rule_set, []);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client home bypass routes the local proxy directly", () => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(process.env.DATA_DIR, "client-settings.json"),
|
||||||
|
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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
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