51 Commits

Author SHA1 Message Date
c6352d781f Add home bypass mode for the Mac client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-05-19 13:47:53 +03:00
d02dbe10de Add Mac client mode and simplify local proxy UI 2026-05-19 13:12:39 +03:00
2ef1e09986 Fix gateway CI to build and deploy via registry
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 2m51s
Build and Deploy Gateway / deploy (push) Successful in 5s
2026-05-09 11:11:39 +03:00
6df8c525ef Fix Gitea gateway build and deploy workflow
Some checks failed
Build and Deploy Gateway / build-and-push (push) Failing after 0s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-05-09 11:10:16 +03:00
f264ce4a2f Switch gateway CI to registry-based build and deploy
Some checks failed
Build and Deploy Gateway / build-and-push (push) Failing after 0s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-05-09 11:08:05 +03:00
371adbcb50 Break gateway build cycle with runtime base bootstrap
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Has been cancelled
2026-05-09 11:03:50 +03:00
3a930c9d8c Fix gateway workflow runner and deploy host
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Has been cancelled
2026-05-09 10:58:19 +03:00
1bdf12f174 Break gateway build cycle with runtime base bootstrap
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2m58s
2026-05-09 10:54:13 +03:00
3e8925c609 Fix gateway workflow runner selection
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 1s
2026-05-09 10:51:23 +03:00
d12b0c01fc Use runtime base to break gateway build cycle
Some checks failed
Build and Deploy Gateway / build-and-push (push) Has been cancelled
Build and Deploy Gateway / deploy (push) Has been cancelled
2026-05-09 10:46:13 +03:00
e16f401dc5 Run gateway builds on 107 and deploy on 111
Some checks failed
Build and Deploy Gateway / build-and-push (push) Has been cancelled
Build and Deploy Gateway / deploy (push) Has been cancelled
2026-05-09 10:41:36 +03:00
68844d67df Add remote build and deploy workflow
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:37:27 +03:00
ec8e748a43 Add routed build and deploy flow for gateway image
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:32:18 +03:00
62f50d9c28 Allow special characters in rule-set tags
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:23:57 +03:00
cab4313c70 Fix LAN proxy binding in routing setup
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:11:40 +03:00
aab7533438 Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
2026-05-09 09:53:12 +03:00
62b39cdf58 style: отформатирован код для улучшения читаемости
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-09 09:24:43 +03:00
6ab5f50f95 feat: добавлена поддержка отображения устройства в журнале трафика
Refs: None
2026-05-09 09:24:34 +03:00
4bb8507e3f feat: добавлены правила маршрутизации по устройствам и управление ими через API
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-09 09:12:03 +03:00
b3fad00f80 feat: добавлена возможность сортировки трафика по частоте и времени
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
Refs: None
2026-05-09 08:38:37 +03:00
5c9a291920 feat: добавлена поддержка кэша прямого обхода с использованием ipset
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-08 22:27:58 +03:00
781cbbb026 feat: добавлено использование хеширования для ключа кеша
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 6s
Refs: None
2026-05-08 21:57:54 +03:00
499d2d3367 fix: удален ненужный параметр SING_BOX_CONFIG из конфигурации сервиса
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 6s
2026-05-08 21:42:45 +03:00
eeec4359b0 feat: добавлена возможность обхода правил для трафика
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
- Реализована функция для включения и отключения обхода правил.
- Обновлены компоненты интерфейса для управления режимом обхода.
- Добавлена обработка состояния обхода в API.

Refs: None
2026-05-08 21:28:42 +03:00
11f2c0ccb2 feat: добавлена группировка трафика с возможностью переключения
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 24s
2026-05-08 21:05:26 +03:00
f89cba4a24 style: отформатирован код для улучшения читаемости
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 22s
2026-05-08 21:02:31 +03:00
49be90a82c feat: добавлена обработка трафика и интерфейс для его отображения
Refs: None
2026-05-08 21:02:18 +03:00
bb7250e4ac feat: добавлена возможность поиска и отображения rule-sets из каталога SagerNet
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-08 20:38:27 +03:00
4f1a2f8bf6 feat: обновлены источники rule-set для sing-box
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 18s
2026-05-08 20:18:55 +03:00
7d1f5f89ed feat: добавлена возможность поиска и декомпиляции rule-sets
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2s
Refs: None
2026-05-08 20:15:33 +03:00
b1c8eea976 style: отформатирован код для улучшения читаемости
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 18s
2026-05-08 19:49:54 +03:00
27b71077b1 feat: добавлены функции для работы с пользовательскими rule-sets
Добавлены новые API-методы для получения и сохранения пользовательских rule-sets. Обновлены компоненты для работы с этими данными, включая интерфейс для добавления и удаления rule-sets.

Refs: None
2026-05-08 19:49:44 +03:00
3e18b833c6 style: исправлены кавычки в коде для единообразия
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-08 19:41:24 +03:00
0cd898d1c1 feat: добавлены функции для работы с PID sing-box
Refs: None
2026-05-08 19:41:17 +03:00
8476ab16e5 feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON.
- Добавлен компонент ServersPage для отображения и управления серверами.
- Реализован компонент SettingsPage для управления подписками и конфигурациями.
- Создан компонент Sidebar для навигации по приложению.
- Добавлен компонент StatusPane для отображения статуса сервера.
- Реализован компонент Toasts для отображения уведомлений.
- Создан компонент Topbar для отображения информации о текущем состоянии.
- Добавлен модуль country.js для определения страны по тегу сервера.

Refs: None
2026-05-08 19:31:49 +03:00
a8f2c6f3f9 fix: добавить ESC-символ в regex парсинга уровня лога sing-box
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 5s
2026-05-08 19:01:30 +03:00
a961b1b415 fix: хранить конфиг sing-box в volume (dataDir), а не в /etc/sing-box
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 5s
2026-05-08 18:50:53 +03:00
7489b5ef97 fix: парсить уровень лога sing-box из stderr вместо hardcode error
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 5s
2026-05-08 18:45:16 +03:00
b716b370ac ci: retry after npm installed on lxc-111
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 20s
2026-05-08 18:37:15 +03:00
abd5a73b51 fix: перенести сборку фронта на хост CI, убрать ui-build стадию из Docker
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
2026-05-08 18:32:26 +03:00
1ed79c3a1e style: исправлены стили и форматирование кода
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
2026-05-08 18:23:56 +03:00
8789496ae6 feat: добавлены компоненты для управления конфигурацией и логами
Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации.

Refs: None
2026-05-08 18:23:29 +03:00
7d41dd86e7 Reduce Docker build memory usage
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
2026-05-08 17:27:56 +03:00
81bed1513c Remove proxy args from gateway build
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 30s
2026-05-08 17:19:43 +03:00
d13eb0a9a4 Fix Gitea workflow labels and runner deployment
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 7s
2026-05-08 16:58:32 +03:00
71f8e0b84c Update vpn proxy routing checks
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Has been cancelled
2026-05-08 16:52:01 +03:00
03885d2e09 Add gateway deploy workflow
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 39s
2026-05-08 16:36:41 +03:00
88eef527d5 Фикс TProxy: добавлен bypass для LOCAL трафика хоста
Some checks failed
Build Gateway Image / build (push) Successful in 3s
Build Gateway Image / deploy (push) Failing after 0s
Добавлено правило --dst-type LOCAL в начало цепочки VPN_PROXY_TPROXY.
Без него ответные пакеты от VPN серверов (storage.dokops.ru, media.dokops.ru)
перехватывались TProxy и sing-box не мог установить VLESS соединение.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 16:36:06 +03:00
c971b40eae Add gateway auto-deploy and tag matching fallback
Some checks failed
Build Gateway Image / build (push) Successful in 3s
Build Gateway Image / deploy (push) Failing after 0s
2026-05-08 16:34:29 +03:00
327561b2e9 Dockerfile: добавлен COPY package.json для поддержки ES modules
Some checks failed
Build Gateway Image / build (push) Failing after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 16:17:02 +03:00
185a311a38 Merge pull request 'develop' (#1) from develop into master
All checks were successful
Build Gateway Image / build (push) Successful in 33s
Reviewed-on: #1
2026-05-08 16:05:18 +03:00
49 changed files with 10012 additions and 932 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.vpn-proxy
_archive
.git
.gitea
.github
.vscode
*.log
.DS_Store

View File

@@ -1,5 +1,13 @@
PORT=3456 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_PORT=8080
PROXY_BIND_IP=0.0.0.0
TPROXY_PORT=7895 TPROXY_PORT=7895
TPROXY_MARK=1 TPROXY_MARK=1
TPROXY_TABLE=100 TPROXY_TABLE=100

View File

@@ -1,28 +1,107 @@
name: Build Gateway Image name: Build and Deploy Gateway
on: on:
push: push:
branches: [master] branches: [master]
workflow_dispatch: 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: jobs:
build: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- name: Clone repository - name: Clone repository
env: env:
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
set -euo pipefail
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" . rm -rf repo
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
cd repo
git checkout ${{ gitea.sha }} git checkout ${{ gitea.sha }}
- name: Build and push gateway image - name: Build and push gateway image
run: | run: |
set -euo pipefail
cd repo
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" 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 echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
docker build -t "${IMAGE}:latest" -t "${IMAGE}:${{ gitea.sha }}" . 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}:latest"
docker push "${IMAGE}:${{ gitea.sha }}" 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

View File

@@ -1,19 +1,23 @@
FROM node:22-bookworm-slim AS ui-build ARG BASE_IMAGE=debian:bookworm-slim
WORKDIR /app FROM ${BASE_IMAGE}
COPY package.json ./
RUN npm install
COPY index.html vite.config.js ./
COPY src/web ./src/web
RUN npm run build
FROM debian:bookworm-slim
ARG SINGBOX_VERSION=1.12.13 ARG SINGBOX_VERSION=1.12.13
ARG INSTALL_RUNTIME_DEPS=true
ARG INSTALL_SINGBOX=true
COPY dist /app/dist
RUN apt-get update \ RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \ apt-get update \
&& rm -rf /var/lib/apt/lists/* && 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 set -eux; \ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
set -eux; \
arch="$(dpkg --print-architecture)"; \ arch="$(dpkg --print-architecture)"; \
case "$arch" in \ case "$arch" in \
amd64) sb_arch="amd64" ;; \ amd64) sb_arch="amd64" ;; \
@@ -24,10 +28,13 @@ RUN set -eux; \
tar -xzf /tmp/sing-box.tgz -C /tmp; \ tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \ mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \ chmod +x /usr/local/bin/sing-box; \
rm -rf /tmp/sing-box* rm -rf /tmp/sing-box*; \
else \
command -v sing-box >/dev/null; \
fi
WORKDIR /app WORKDIR /app
COPY --from=ui-build /app/dist /app/dist COPY package.json /app/package.json
COPY src/server /app/src/server COPY src/server /app/src/server
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
@@ -36,7 +43,10 @@ RUN chmod +x /entrypoint.sh \
ENV PORT=3456 \ ENV PORT=3456 \
PROXY_PORT=8080 \ PROXY_PORT=8080 \
PROXY_BIND_IP=0.0.0.0 \
TPROXY_PORT=7895 \ TPROXY_PORT=7895 \
DIRECT_BYPASS_CACHE=false \
RULE_SET_DOWNLOAD_DETOUR=vpn \
DATA_DIR=/var/lib/vpn-proxy \ DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \ SING_BOX_CONFIG=/etc/sing-box/config.json \
SING_BOX_CACHE=/var/lib/sing-box/cache.db SING_BOX_CACHE=/var/lib/sing-box/cache.db

54
Dockerfile.client Normal file
View 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
View 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*

433
README.md
View File

@@ -1,34 +1,423 @@
# VPN Proxy
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
## macOS: локальный Docker-клиент
Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
```
После запуска:
- UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8080`
В 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 # VPN Proxy Gateway
Новая версия проекта начинается с `gateway`-режима: контейнер поднимается в `network_mode: host`, применяет TProxy-правила на хосте и запускает `sing-box` как прозрачный gateway для устройств в локальной сети. Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.
## Что уже заложено Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
- Web UI на Vite + React. ---
- Один простой Node control-server вместо отдельного backend framework.
- Парсинг subscription URL: JSON config, base64 список, plain-text VLESS links. ## Архитектура
- Routing lists управляются из UI: можно отправлять отдельные домены/CIDR/порты в `direct`, `vpn` или `block`.
- Генерация `sing-box` config для gateway: ```
- `tproxy` inbound на `7895`; Клиент (ПК/телефон)
- `mixed` inbound на `8080`; │ TCP/UDP трафик
- private IP ranges напрямую;
- RU rule sets напрямую; [Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
- остальное через выбранный outbound.
- Docker entrypoint с idempotent TProxy setup/cleanup.
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-стримы для веб-интерфейса.
---
## Стек
| Слой | Технология |
| ---------------- | ------------------------------------------------------------- |
| Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
| Перехват трафика | iptables TProxy + iproute2 policy routing |
| Bypass-кэш | опциональный ipset `hash:ip` с TTL |
| VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
| API-сервер | Node.js 18, plain `http` (без фреймворков) |
| Веб-интерфейс | React 18 + Vite 7, SPA |
---
## Как работает прозрачное проксирование
### 1. TProxy и policy routing
При старте контейнера `entrypoint.sh` настраивает ядро:
```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
# Цепочка iptables (порядок правил — критичен)
iptables -t mangle -N VPN_PROXY_TPROXY
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
-m mark --mark 1 → RETURN # уже помеченные пакеты
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
-p tcp → TPROXY :7895 mark 1
-p udp → TPROXY :7895 mark 1
iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
```
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
### 2. Маршрутизация внутри sing-box
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
| Приоритет | Условие | Действие |
| --------- | ------------------------------------------- | ---------------------------------------- |
| 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` |
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
### 3. Bypass Mode (весь трафик напрямую)
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
---
## Direct Bypass Cache (ipset)
Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`.
Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace.
**Цепочка событий:**
1. sing-box маршрутизирует соединение как `direct`, пишет в лог:
`[TCP] 192.168.1.5:54321 --> 203.0.113.10:443 outbound/direct[direct]`
2. Node.js парсит строку (regex `-->` + `outbound/`). Если `category === "direct"` и назначение — IPv4-адрес:
```bash
ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
```
3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:
```
-m set --match-set vpn_direct_bypass dst → RETURN
```
Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.
4. Запись истекает через TTL (по умолчанию 1 час).
```
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
DIRECT_BYPASS_TTL=3600 # TTL в секундах
```
## Профили устройств
Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:
```json
{
"defaultTransparentMode": "vpn",
"proxyDefaultMode": "vpn",
"devices": [
{
"id": "gaming-pc",
"name": "Gaming PC",
"ip": "192.168.1.50",
"mac": "",
"mode": "direct",
"enabled": true
},
{
"id": "phone",
"name": "Phone",
"ip": "192.168.1.60",
"mode": "vpn",
"enabled": true
}
]
}
```
| Mode | Что делает |
| -------- | ----------------------------------------------------------------- |
| `direct` | fallback устройства после global rules → `direct` |
| `vpn` | fallback устройства после global rules → выбранный VPN |
| `block` | fallback устройства после global rules → `block` |
| `rules` | не задаёт fallback устройства; используется transparent default |
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
---
## Кастомные правила маршрутизации
Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.
| Поле | Тип | Описание |
| ---------------- | ---------------------------- | ------------------------------------------- |
| `name` | string | Название правила |
| `enabled` | bool | Вкл/выкл |
| `outbound` | `direct` \| `vpn` \| `block` | Куда отправить трафик |
| `domains` | string[] | Точные домены (`example.com`) |
| `domainSuffixes` | string[] | Суффикс домена (`.example.com` + поддомены) |
| `domainKeywords` | string[] | Keyword в имени хоста |
| `ipCidrs` | string[] | IP-диапазоны CIDR |
| `ports` | string[] | Порты или диапазоны (`443`, `8000-9000`) |
| `networks` | `tcp` \| `udp` | Протокол |
| `ruleSets` | string[] | Ссылки на remote rule-set |
UI автоматически детектирует конфликты — когда правило полностью перекрывается предыдущим.
### Remote Rule Sets
В **Настройках** можно добавить произвольные rule-set:
```json
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
```
sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.
---
## Подписки
Поддерживаемые форматы:
- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)
После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.
Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.
---
## Просмотр трафика
Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).
### Парсинг логов sing-box
Node.js читает stderr sing-box и извлекает трафик двумя шагами:
```
[router] match[2][my-rule] => outbound/direct[direct] ← имя правила
[TCP] 192.168.1.5:PORT --> example.com:443 outbound/vpn[tag] ← соединение
```
1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
3. Тип трафика: `direct` / `vpn` / `block` по outbound
4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true`
### Группировка и сортировка
`(category, host, port, matchedRule)` объединяются в группу с счётчиком:
- **По частоте** — самые частые наверху (по умолчанию)
- **По времени** — последние наверху
---
## Проверка маршрута
Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
1. private IP → direct
2. global custom rules
3. geoip-ru / geosite-category-ru → direct
4. `tproxy-in` + device default
5. `mixed-in` + proxy default
6. final → direct
---
## Быстрый старт ## Быстрый старт
```bash ```bash
cp .env.example .env # Сборка фронтенда
docker compose -f docker-compose.gateway.yml up -d --build npm install && npm run build
# Запуск контейнера
docker compose -f docker-compose.gateway.yml up -d
``` ```
UI будет доступен на хосте по `http://<gateway-host>:3456`. Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:
## Важные ограничения v0.1 ```bash
BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim \
docker compose -f docker-compose.gateway.yml build
- IPv4 TProxy first. IPv6 routing будет отдельным этапом. docker compose -f docker-compose.gateway.yml up -d
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать клиентам DNS через роутер/DHCP. ```
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state. Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:
- Gateway не видит process name на клиентском ПК, поэтому правила для игр задаются через домены, suffix, IP CIDR и порты.
```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
View 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:

View File

@@ -3,6 +3,11 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile 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 container_name: vpn-proxy-gateway
network_mode: host network_mode: host
cap_add: cap_add:
@@ -12,12 +17,17 @@ services:
- .env - .env
environment: environment:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db SING_BOX_CACHE: /var/lib/sing-box/cache.db
volumes: volumes:
- vpn-proxy-data:/var/lib/vpn-proxy - vpn-proxy-data:/var/lib/vpn-proxy
- sing-box-cache:/var/lib/sing-box - sing-box-cache:/var/lib/sing-box
restart: unless-stopped 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: volumes:
vpn-proxy-data: vpn-proxy-data:

22
docker-compose.server.yml Normal file
View File

@@ -0,0 +1,22 @@
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_CONFIG: /etc/sing-box/config.json
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
volumes:
vpn-proxy-data:
sing-box-cache:

View 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.

View 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
View 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

View File

@@ -5,7 +5,18 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_MARK="${TPROXY_MARK:-1}"
TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" 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}" 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() { log() {
printf '[gateway-entrypoint] %s\n' "$*" printf '[gateway-entrypoint] %s\n' "$*"
@@ -15,6 +26,13 @@ ipt() {
iptables -w "$@" 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() { cleanup_tproxy() {
log "cleanup tproxy rules" log "cleanup tproxy rules"
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
@@ -22,6 +40,37 @@ cleanup_tproxy() {
ipt -t mangle -X "$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 rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route flush 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() { setup_tproxy() {
@@ -32,8 +81,16 @@ setup_tproxy() {
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE" ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
ipt -t mangle -N "$TPROXY_CHAIN" 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 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 for cidr in $BYPASS_CIDRS; do
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
done done
@@ -43,7 +100,9 @@ setup_tproxy() {
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN" ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
} }
setup_direct_bypass_set
setup_tproxy setup_tproxy
setup_proxy_firewall
node /app/src/server/index.js & node /app/src/server/index.js &
APP_PID=$! APP_PID=$!
@@ -52,6 +111,7 @@ shutdown() {
log "shutdown requested" log "shutdown requested"
kill "$APP_PID" 2>/dev/null || true kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true
cleanup_proxy_firewall
cleanup_tproxy cleanup_tproxy
} }
@@ -59,5 +119,6 @@ trap 'shutdown; exit 0' SIGTERM SIGINT
wait "$APP_PID" wait "$APP_PID"
STATUS=$? STATUS=$?
cleanup_proxy_firewall
cleanup_tproxy cleanup_tproxy
exit "$STATUS" exit "$STATUS"

1726
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,16 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"test": "node --test",
"start": "node src/server/index.js" "start": "node src/server/index.js"
}, },
"dependencies": { "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", "@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
}, "vite": "^7.0.0"
"devDependencies": {} }
} }

View 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
View 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
View 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
View 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

View 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;
}

View File

@@ -1,21 +1,28 @@
import path from 'node:path'; import path from "node:path";
const dataDir = process.env.DATA_DIR || path.resolve('.vpn-proxy'); const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
export const settings = { export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: Number(process.env.PORT || 3456), port: Number(process.env.PORT || 3456),
proxyPort: Number(process.env.PROXY_PORT || 8080), proxyPort: Number(process.env.PROXY_PORT || 8080),
tproxyPort: Number(process.env.TPROXY_PORT || 7895), tproxyPort: Number(process.env.TPROXY_PORT || 7895),
bindIp: process.env.PROXY_BIND_IP || '0.0.0.0', bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir, dataDir,
distDir: process.env.DIST_DIR || '/app/dist', distDir: process.env.DIST_DIR || "/app/dist",
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json', configPath:
cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db', process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
statePath: path.join(dataDir, 'state.json'), cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
customRulesPath: path.join(dataDir, 'custom-rules.json'), statePath: path.join(dataDir, "state.json"),
subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'), customRulesPath: path.join(dataDir, "custom-rules.json"),
hwidPath: path.join(dataDir, 'hwid'), customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false', clientSettingsPath: path.join(dataDir, "client-settings.json"),
logLevel: process.env.LOG_LEVEL || 'info', devicesPath: path.join(dataDir, "devices.json"),
appName: 'VPN Proxy Gateway', 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
View 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,
}));
}

File diff suppressed because it is too large Load Diff

50
src/server/ping.js Normal file
View 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
View 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;
}

View File

@@ -1,45 +1,108 @@
import fs from 'node:fs'; import fs from "node:fs";
import path from 'node:path'; import path from "node:path";
import { settings } from './config.js'; 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 PROXY_TYPES = new Set([
const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']); "vless",
"vmess",
"trojan",
"shadowsocks",
"hysteria2",
]);
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
function clone(value) { function clone(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function findOutbound(subscriptionConfig, selectedTag) { function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : []; const outbounds = Array.isArray(subscriptionConfig?.outbounds)
return outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type)); ? 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 ruleSets() { function readCustomRuleSets() {
if (!settings.routingRuDirect) return []; try {
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
const data = JSON.parse(
fs.readFileSync(settings.customRuleSetsPath, "utf8"),
);
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
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', type: "remote",
tag: 'geoip-ru', tag: "geoip-ru",
format: 'binary', format: "binary",
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs', url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
download_detour: 'direct', download_detour: downloadDetour,
}, },
{ {
type: 'remote', type: "remote",
tag: 'geosite-category-ru', tag: "geosite-category-ru",
format: 'binary', format: "binary",
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs', url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
download_detour: 'direct', 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) { function uniqueClean(values) {
return Array.from( return Array.from(
new Set( new Set(
(Array.isArray(values) ? values : []) (Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim()) .map((value) => String(value || "").trim())
.filter(Boolean), .filter(Boolean),
), ),
); );
@@ -51,17 +114,19 @@ function parsePorts(values) {
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535); .filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
} }
function toSingboxRule(customRule, vpnTag) { function toSingboxRule(customRule, vpnTag, baseRule = {}) {
if (!customRule?.enabled) return null; if (!customRule?.enabled) return null;
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null; if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
const rule = {}; const rule = { ...baseRule };
const domains = uniqueClean(customRule.domains); const domains = uniqueClean(customRule.domains);
const domainSuffixes = uniqueClean(customRule.domainSuffixes); const domainSuffixes = uniqueClean(customRule.domainSuffixes);
const domainKeywords = uniqueClean(customRule.domainKeywords); const domainKeywords = uniqueClean(customRule.domainKeywords);
const ipCidrs = uniqueClean(customRule.ipCidrs); const ipCidrs = uniqueClean(customRule.ipCidrs);
const ports = parsePorts(customRule.ports); const ports = parsePorts(customRule.ports);
const networks = uniqueClean(customRule.networks).filter((network) => ['tcp', 'udp'].includes(network)); const networks = uniqueClean(customRule.networks).filter((network) =>
["tcp", "udp"].includes(network),
);
if (domains.length) rule.domain = domains; if (domains.length) rule.domain = domains;
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes; if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
@@ -70,59 +135,159 @@ function toSingboxRule(customRule, vpnTag) {
if (ports.length) rule.port = ports; if (ports.length) rule.port = ports;
if (networks.length) rule.network = networks; if (networks.length) rule.network = networks;
const ruleSetsRef = uniqueClean(customRule.ruleSets);
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
if ( if (
!rule.domain && !rule.domain &&
!rule.domain_suffix && !rule.domain_suffix &&
!rule.domain_keyword && !rule.domain_keyword &&
!rule.ip_cidr && !rule.ip_cidr &&
!rule.port && !rule.port &&
!rule.network !rule.network &&
!rule.rule_set
) { ) {
return null; return null;
} }
rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound; rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
return rule; return rule;
} }
function customRouteRules(customRules, vpnTag) { function customRouteRules(customRules, vpnTag, baseRule = {}) {
return (Array.isArray(customRules) ? customRules : []) return (Array.isArray(customRules) ? customRules : [])
.map((rule) => toSingboxRule(rule, vpnTag)) .map((rule) => toSingboxRule(rule, vpnTag, baseRule))
.filter(Boolean); .filter(Boolean);
} }
function routeRules(customRules, vpnTag) { // ─── 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 = [ const rules = [
{ {
ip_is_private: true, ip_is_private: true,
outbound: 'direct', outbound: "direct",
}, },
]; ];
// Global rules apply to every inbound before contextual fallbacks.
rules.push(...customRouteRules(customRules, vpnTag)); rules.push(...customRouteRules(customRules, vpnTag));
if (settings.routingRuDirect) { const ruRule = ruDirectRule();
rules.push({ if (ruRule) rules.push(ruRule);
rule_set: ['geoip-ru', 'geosite-category-ru'],
outbound: 'direct', 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; return rules;
} }
export function buildGatewayConfig(subscriptionConfig, selectedTag) { export function buildGatewayConfig(
subscriptionConfig,
selectedTag,
{ bypassAll = false } = {},
) {
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag); const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
if (!selectedOutbound) { if (!selectedOutbound) {
throw new Error(`Selected outbound not found: ${selectedTag}`); throw new Error(`Outbound не найден: ${selectedTag}`);
} }
const vpnOutbound = clone(selectedOutbound); const vpnOutbound = clone(selectedOutbound);
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp'; 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 { return {
log: { log: {
level: settings.logLevel, level: settings.logLevel,
@@ -137,33 +302,22 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
dns: { dns: {
independent_cache: true, independent_cache: true,
}, },
inbounds: [ inbounds,
{
type: 'tproxy',
tag: 'tproxy-in',
listen: '::',
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
},
{
type: 'mixed',
tag: 'mixed-in',
listen: settings.bindIp,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
],
outbounds: [ outbounds: [
vpnOutbound, vpnOutbound,
{ type: 'direct', tag: 'direct' }, { type: "direct", tag: "direct" },
{ type: 'block', tag: 'block' }, { type: "block", tag: "block" },
], ],
route: { route: {
rule_set: ruleSets(), rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), rules: bypassAll
final: vpnOutbound.tag, ? [{ ip_is_private: true, outbound: "direct" }]
: clientMode
? proxyOnlyRules
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
includeTransparent: !clientMode,
}),
final: "direct",
auto_detect_interface: true, auto_detect_interface: true,
}, },
}; };
@@ -171,5 +325,24 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
export function writeSingboxConfig(config) { export function writeSingboxConfig(config) {
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true }); fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8'); 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);
}
} }

View File

@@ -1,164 +1,290 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './styles.css'; 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';
function formatBytes(value) { const ROLLBACK_WINDOW_MS = 12_000;
if (!value) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
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]}`;
}
function maskUrl(value) { function getInitialPage() {
if (!value) return ''; const hash = window.location.hash.replace('#/', '').replace('#', '');
try { const valid = ['overview', 'servers', 'routing', 'logs', 'settings'];
const url = new URL(value); return valid.includes(hash) ? hash : 'overview';
return `${url.hostname}/...`;
} catch {
return value.length > 48 ? `${value.slice(0, 48)}...` : value;
}
} }
function App() { function App() {
const [page, setPage] = useState(getInitialPage());
const [state, setState] = useState(null); const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [customRules, setCustomRules] = 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 [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [log, setLog] = useState([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved'); 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 rulesDirtyRef = useRef(false);
const rulesSaveTimerRef = useRef(null); const rulesSaveTimerRef = useRef(null);
const rulesRevisionRef = useRef(0); const rulesRevisionRef = useRef(0);
const rollbackTimerRef = useRef(null);
const userTraffic = useMemo(() => { function pushToast(toast) {
const info = state?.userInfo; const id = `t-${Date.now()}-${Math.random()}`;
if (!info) return 'нет данных'; setToasts((prev) => [...prev, { id, ...toast }]);
const used = formatBytes((info.upload || 0) + (info.download || 0)); }
const total = info.total ? formatBytes(info.total) : 'без лимита'; function dismissToast(id) {
return `${used} / ${total}`; setToasts((prev) => prev.filter((t) => t.id !== id));
}, [state]);
function addLog(message) {
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
setLog((items) => [{ time, message }, ...items].slice(0, 8));
} }
async function loadState() { function navigate(p) {
const response = await fetch('/api/state'); setPage(p);
const data = await response.json(); window.location.hash = `#/${p}`;
setState(data);
setServers(data.servers || []);
if (!rulesDirtyRef.current) {
setCustomRules(data.customRules || []);
}
setSelectedTag(data.selectedTag || '');
if (data.subscriptionUrl && !subscriptionUrl) setSubscriptionUrl(data.subscriptionUrl);
} }
useEffect(() => { useEffect(() => {
loadState().catch(() => {}); 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); const timer = setInterval(() => loadState().catch(() => {}), 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
useEffect(() => { useEffect(() => {
return () => { if (state?.mode === 'client' && page !== 'overview') {
navigate('overview');
}
}, [state?.mode, page]);
useEffect(() => () => {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
}; if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
}, []); }, []);
async function fetchServers() { async function withBusy(label, fn, { quiet = false } = {}) {
setBusy(true); setBusy(true);
setError(''); setError('');
addLog(`SYNC ${maskUrl(subscriptionUrl)}`);
try { try {
const response = await fetch('/api/subscription/fetch', { const result = await fn();
method: 'POST', if (!quiet && label) pushToast({ kind: 'success', title: label });
headers: { 'content-type': 'application/json' }, return result;
body: JSON.stringify({ url: subscriptionUrl }), } catch (err) {
}); setError(err.message);
const data = await response.json(); pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
if (!response.ok || !data.success) throw new Error(data.error || 'sync failed'); throw err;
} finally {
setBusy(false);
}
}
// === Subscription ===
async function fetchSubscription() {
return withBusy('Подписка обновлена', async () => {
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
setServers(data.servers || []); setServers(data.servers || []);
setSelectedTag(data.servers?.[0]?.tag || ''); if (!selectedTag && data.servers?.length) {
addLog(`FOUND ${data.servers.length} servers`); setSelectedTag(data.servers[0].tag);
setPendingTag(data.servers[0].tag);
}
await loadState(); await loadState();
} catch (err) {
setError(err.message);
addLog(`ERROR ${err.message}`);
} finally {
setBusy(false);
}
}
async function applyServer() {
setBusy(true);
setError('');
addLog(`APPLY ${selectedTag}`);
try {
const response = await fetch('/api/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ selectedTag }),
}); });
const data = await response.json(); }
if (!response.ok || !data.success) throw new Error(data.error || 'apply failed');
addLog(`SING-BOX ${data.singboxRunning ? 'RUNNING' : 'STOPPED'}`); async function forgetSubscription() {
if (!confirm('Удалить подписку и остановить sing-box?')) return;
return withBusy('Подписка удалена', async () => {
await api.subscription.forget();
setSubscriptionUrl('');
setServers([]);
setSelectedTag('');
setPendingTag('');
await loadState(); await loadState();
} catch (err) { });
setError(err.message); }
addLog(`ERROR ${err.message}`);
} finally { // === Apply with rollback offer ===
setBusy(false); 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() { function emptyRule() {
return { return {
id: `rule-${Date.now()}`, id: `rule-${Date.now()}`,
name: 'Новый список', name: 'Новое правило',
enabled: true, enabled: true,
outbound: 'direct', outbound: 'direct',
domains: [], domains: [], domainSuffixes: [], domainKeywords: [],
domainSuffixes: [], ipCidrs: [], ports: [], networks: [],
domainKeywords: [],
ipCidrs: [],
ports: [],
networks: [],
}; };
} }
function listToText(value) {
return Array.isArray(value) ? value.join('\n') : '';
}
function textToList(value) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
function updateRule(id, patch) {
setCustomRules((rules) => {
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
queueRulesSave(nextRules);
return nextRules;
});
}
function queueRulesSave(nextRules) { function queueRulesSave(nextRules) {
rulesDirtyRef.current = true; rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1; const revision = rulesRevisionRef.current + 1;
@@ -166,42 +292,28 @@ function App() {
setRulesSaveStatus('pending'); setRulesSaveStatus('pending');
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesSaveTimerRef.current = setTimeout(() => { rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
saveRules(nextRules, { silent: true, revision });
}, 700);
} }
async function saveRules(nextRules = customRules, options = {}) { async function saveRules(nextRules = customRules, options = {}) {
const { silent = false, revision = rulesRevisionRef.current + 1 } = options; const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
if (!silent) setBusy(true);
setError(''); setError('');
if (!silent) addLog('SAVE ROUTING RULES');
setRulesSaveStatus('saving'); setRulesSaveStatus('saving');
try { try {
const response = await fetch('/api/rules', { const data = await api.rules.save(nextRules);
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ rules: nextRules }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'rules save failed');
if (rulesRevisionRef.current === revision) { if (rulesRevisionRef.current === revision) {
rulesDirtyRef.current = false; rulesDirtyRef.current = false;
setCustomRules(data.rules || []); setCustomRules(data.rules || []);
setRulesSaveStatus('saved'); setRulesSaveStatus('saved');
addLog(`RULES SAVED ${data.rules.length}`);
await loadState(); await loadState();
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
} else { } else {
setRulesSaveStatus('pending'); setRulesSaveStatus('pending');
} }
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
setRulesSaveStatus('error'); setRulesSaveStatus('error');
addLog(`ERROR ${err.message}`); pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
} finally {
if (!silent) setBusy(false);
} }
} }
@@ -213,238 +325,240 @@ function App() {
saveRules(customRules, { silent: false, 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() { function addRule() {
setCustomRules((rules) => { setCustomRules((rules) => {
const nextRules = [emptyRule(), ...rules]; const next = [emptyRule(), ...rules];
queueRulesSave(nextRules); queueRulesSave(next);
return nextRules; return next;
});
}
function addRuleFromTemplate(tpl) {
setCustomRules((rules) => {
const next = [tpl, ...rules];
queueRulesSave(next);
return next;
}); });
} }
function removeRule(id) { function removeRule(id) {
setCustomRules((rules) => { setCustomRules((rules) => {
const nextRules = rules.filter((rule) => rule.id !== id); const next = rules.filter((r) => r.id !== id);
queueRulesSave(nextRules); queueRulesSave(next);
return nextRules; 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 ( return (
<main className="shell"> <div className="app">
<section className="hero panel"> <Topbar
<div> state={state}
<p className="eyebrow">VPN Proxy / Gateway Mode</p> status={status}
<h1>Transparent gateway for the whole network</h1> activeServer={activeServer}
<p className="lead"> dirty={dirty}
Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов. onRestart={restartSingbox}
</p> onTryApply={rollback}
</div>
<div className="status-card">
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
<div>
<strong>{state?.singboxRunning ? 'sing-box running' : 'sing-box standby'}</strong>
<small>{state?.selectedTag || 'сервер не выбран'}</small>
</div>
</div>
</section>
<section className="grid">
<div className="panel primary-flow">
<div className="section-title">
<span>1</span>
<h2>Subscription</h2>
</div>
<label className="field">
<span>Subscription URL</span>
<input
value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)}
placeholder="https://provider.example/sub/..."
/> />
</label>
<button className="button" disabled={busy || !subscriptionUrl} onClick={fetchServers}> <div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
{busy ? 'Working...' : 'Parse subscription'} {!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> </button>
)}
<div className="section-title compact">
<span>2</span>
<h2>Servers</h2>
</div> </div>
<div className="server-list">
{servers.length === 0 && <div className="empty">Серверы еще не загружены</div>}
{servers.map((server) => (
<button
key={server.tag}
className={server.tag === selectedTag ? 'server active' : 'server'}
onClick={() => setSelectedTag(server.tag)}
>
<strong>{server.tag}</strong>
<small>{server.type} / {server.server}:{server.server_port}</small>
</button>
))}
</div>
<button className="button apply" disabled={busy || !selectedTag} onClick={applyServer}>
Apply selected gateway route
</button>
{error && <div className="error">{error}</div>}
</div>
<aside className="panel details">
<div className="section-title">
<span>3</span>
<h2>Gateway runtime</h2>
</div>
<dl>
<div><dt>UI</dt><dd>:{state?.port || 3456}</dd></div>
<div><dt>Mixed proxy</dt><dd>:{state?.proxyPort || 8080}</dd></div>
<div><dt>TProxy</dt><dd>:{state?.tproxyPort || 7895}</dd></div>
<div><dt>RU direct</dt><dd>{state?.routingRuDirect ? 'enabled' : 'disabled'}</dd></div>
<div><dt>Traffic</dt><dd>{userTraffic}</dd></div>
</dl>
<div className="route-card">
<span>Routing policy</span>
<p>private IP -> direct</p>
<p>geoip-ru/geosite-category-ru -> direct</p>
<p>everything else -> selected VPN outbound</p>
</div>
<div className="logs">
{log.length === 0 && <p>Waiting for actions...</p>}
{log.map((entry) => (
<p key={`${entry.time}-${entry.message}`}><span>{entry.time}</span> {entry.message}</p>
))}
</div>
</aside>
</section>
<section className="panel rules-panel">
<div className="rules-header">
<div className="section-title">
<span>4</span>
<h2>Routing lists</h2>
</div>
<div className="rules-actions">
<button className="ghost-button" type="button" onClick={addRule}>Add list</button>
<button className="ghost-button solid" type="button" disabled={busy || rulesSaveStatus === 'saving'} onClick={saveRulesNow}>
{rulesSaveStatus === 'saving' ? 'Saving...' : rulesSaveStatus === 'pending' ? 'Save now' : rulesSaveStatus === 'error' ? 'Retry save' : 'Saved'}
</button>
</div>
</div>
<p className="rules-note">
Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит.
</p>
<div className="rule-grid">
{customRules.length === 0 && (
<div className="empty rule-empty">
Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`.
</div> </div>
)} )}
{customRules.map((rule) => ( {(page === 'servers' && dirtyServer) && (
<article className="rule-card" key={rule.id}> <div className="sticky-bar">
<div className="rule-top"> <div className="flex">
<input <span className="dot warning" />
value={rule.name} <strong>Сервер не применён</strong>
onChange={(event) => updateRule(rule.id, { name: event.target.value })} <small className="muted">Выбран: {pendingTag}</small>
placeholder="Название списка"
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={rule.enabled}
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
/>
enabled
</label>
</div> </div>
<div className="btn-group">
<label className="field"> <button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
<span>Route to</span> <button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
<select value={rule.outbound} onChange={(event) => updateRule(rule.id, { outbound: event.target.value })}> Применить
<option value="direct">direct</option>
<option value="vpn">vpn</option>
<option value="block">block</option>
</select>
</label>
<div className="rule-fields">
<label className="field">
<span>Domains exact</span>
<textarea
value={listToText(rule.domains)}
onChange={(event) => updateRule(rule.id, { domains: textToList(event.target.value) })}
placeholder="riotgames.com"
/>
</label>
<label className="field">
<span>Domain suffixes</span>
<textarea
value={listToText(rule.domainSuffixes)}
onChange={(event) => updateRule(rule.id, { domainSuffixes: textToList(event.target.value) })}
placeholder={'leagueoflegends.com\nriotcdn.net'}
/>
</label>
<label className="field">
<span>IP CIDR</span>
<textarea
value={listToText(rule.ipCidrs)}
onChange={(event) => updateRule(rule.id, { ipCidrs: textToList(event.target.value) })}
placeholder="104.160.128.0/19"
/>
</label>
<label className="field">
<span>Ports</span>
<textarea
value={listToText(rule.ports)}
onChange={(event) => updateRule(rule.id, { ports: textToList(event.target.value) })}
placeholder={'5000\n5223'}
/>
</label>
</div>
<div className="rule-footer">
<label className="checkbox-label">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(event) => {
const set = new Set(rule.networks || []);
event.target.checked ? set.add('tcp') : set.delete('tcp');
updateRule(rule.id, { networks: Array.from(set) });
}}
/>
tcp
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(event) => {
const set = new Set(rule.networks || []);
event.target.checked ? set.add('udp') : set.delete('udp');
updateRule(rule.id, { networks: Array.from(set) });
}}
/>
udp
</label>
<button className="danger-button" type="button" onClick={() => removeRule(rule.id)}>
Remove
</button> </button>
</div> </div>
</article>
))}
</div> </div>
</section> )}
</main> </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>
); );
} }

122
src/web/api.js Normal file
View 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" }),
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>Может занять 1030 секунд</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>
</>
);
}

View 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>
);
}

View 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>Может занять 1030 секунд</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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 });
}

View 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);
}

View 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" },
]);
});