diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7bb7c8d --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +PORT=3456 +PROXY_PORT=8080 +TPROXY_PORT=7895 +TPROXY_MARK=1 +TPROXY_TABLE=100 +TPROXY_CHAIN=VPN_PROXY_TPROXY +ROUTING_RU_DIRECT=true +LOG_LEVEL=info diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml deleted file mode 100644 index 7b7bd7a..0000000 --- a/.gitea/workflows/docker-build.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Build and Deploy Sing-proxy - -on: - push: - branches: [master] - workflow_dispatch: - -env: - DEPLOY_PATH: /opt/vpn-proxy - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Clone repository - env: - GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') - git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" . - git checkout ${{ gitea.sha }} - - - name: Build and push image - run: | - REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') - IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/sing-proxy" - - echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin - - docker build \ - -f docker/Dockerfile.singbox \ - -t "${IMAGE}:latest" \ - . - - docker push "${IMAGE}:latest" - echo "Pushed: ${IMAGE}:latest" - - deploy: - needs: build - runs-on: lxc-111 - steps: - - name: Deploy to LXC 111 - run: | - REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') - IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/sing-proxy" - - echo "Logging into registry..." - echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin - - echo "Pulling latest image..." - docker pull "${IMAGE}:latest" - - echo "Stopping old container..." - docker stop sing-proxy 2>/dev/null || true - docker rm sing-proxy 2>/dev/null || true - - echo "Starting new container..." - docker run -d \ - --name sing-proxy \ - --network host \ - --restart unless-stopped \ - -e PORT=3456 \ - -e PROXY_PORT=8080 \ - -v ${{ env.DEPLOY_PATH }}/data:/app/data \ - --memory=256m \ - "${IMAGE}:latest" - - echo "Deployment complete!" - sleep 3 - docker ps | grep sing-proxy diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml new file mode 100644 index 0000000..8c461b7 --- /dev/null +++ b/.gitea/workflows/gateway-build.yml @@ -0,0 +1,28 @@ +name: Build Gateway Image + +on: + push: + branches: [master] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Clone repository + env: + GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') + git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" . + git checkout ${{ gitea.sha }} + + - name: Build and push gateway image + run: | + REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') + IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" + + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin + docker build -t "${IMAGE}:latest" -t "${IMAGE}:${{ gitea.sha }}" . + docker push "${IMAGE}:latest" + docker push "${IMAGE}:${{ gitea.sha }}" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 6877a2f..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,104 +0,0 @@ -# Project Guidelines - -## Overview - -VPN-Proxy is a self-hosted VPN/proxy management system using **sing-box** as the core proxy engine (VLESS + REALITY TLS). It consists of a NestJS (TypeScript) backend, a vanilla HTML/JS frontend, PowerShell scripts for Windows management, and Docker for deployment. Documentation and UI are in **Russian**. - -## Architecture - -``` -Browser → NestJS web server (PORT, default 3456) - ├─ Serves index.html with SSI-like includes () - └─ API endpoints in web/api/src/proxy/proxy.controller.ts - ↓ writes config - data/client.json → sing-box binary (PROXY_PORT, default 8080) - ↓ reload via HTTP to RELOAD_PORT (9090, internal) - ↓ - VPN traffic out -``` - -### Key layers - -| Layer | Location | Notes | -|-------|----------|-------| -| Frontend | `web/index.html`, `web/components/`, `web/static/` | Tailwind via CDN, no build step | -| Backend | `web/api/` | NestJS + TypeScript, minimal deps | -| Proxy core | `docker/entrypoint.sh` + sing-box binary | Config in `data/client.json` | -| Windows client | `manage.ps1`, `scripts/` | PowerShell 7+ required, runs as Admin | -| Docker | `docker-compose.yml` (dev), `docker-compose.server.yml` (prod, host network) | - -### State files (`data/`) - -All JSON. Do not change their structure without updating both backend and JS consumers: -- `client.json` — active sing-box config -- `subscription.json` — subscription URL + selected server -- `fallback.json` — fallback proxy settings -- `proxy_enabled.json` — on/off toggle -- `start_time.json` — uptime timestamp -- `hwid` — immutable device ID (16-char hex), generated once - -## Build and Run - -```powershell -# Docker (dev, bridged network) -docker compose up -d # starts on localhost:3456 + 8080 -docker compose up -d --build # rebuild after changes - -# Docker (Linux VPS, host network for UDP) -docker compose -f docker-compose.server.yml up -d - -# Logs -docker logs -f sing-proxy - -# Windows native (PowerShell 7, Admin) -.\manage.ps1 - -# Backend dev (local) -cd web/api -npm install -npm run start:dev -``` - -Environment variables: `PORT` (3456), `PROXY_PORT` (8080), `RELOAD_PORT` (9090), `PROXY_BIND_IP` (0.0.0.0). - -## Conventions - -### Code style -- **TypeScript**: NestJS conventions — modules, controllers, services. `camelCase` for methods, `PascalCase` for classes -- **PowerShell**: `PascalCase` functions (e.g., `Write-Success`, `Manage-ScheduledTask`) -- **JSON keys**: `camelCase` (e.g., `serverPort`, `selectedServer`) -- **HTML element IDs**: `camelCase` (e.g., `subUrlInput`, `fallbackToggle`) - -### Adding features -- New API endpoint → controller in `web/api/src/proxy/proxy.controller.ts` + JS call in `web/static/js/app.js` -- Business logic → `web/api/src/proxy/proxy.service.ts` -- VLESS config changes → `web/api/src/vless/vless.service.ts` -- Persistent state → `web/api/src/storage/storage.service.ts` (JSON file I/O) -- Network utilities → `web/api/src/network/network.service.ts` -- Windows scripts → `scripts/setup-*.ps1`, shared helpers in `scripts/lib/` - -### Backend module structure -``` -web/api/src/ - main.ts — Bootstrap & static assets - app.module.ts — Root module - config/config.ts — Environment configuration - storage/ — JSON file persistence + HWID - vless/ — VLESS URL parsing + sing-box config generation - network/ — TCP latency + proxy performance measurement - proxy/ — API controller + business logic service -``` - -### VLESS handling -- Parsing is strict: requires `vless://uuid@host:port?pbk=...&sid=...` format (REALITY params mandatory) -- Subscription URLs must be `http://` or `https://` only - -## Pitfalls - -- **Windows Docker cannot use `network_mode: host`** — UDP (Discord voice, games) won't work in Docker on Windows. Use native sing-box via `manage.ps1` instead. -- **Port 9090 is internal only** — used for reload triggers via netcat, never expose externally. -- **`hwid` is immutable** — after first generation, changing it requires manual file deletion. -- **DOS line endings** — the Dockerfile runs `dos2unix` on shell scripts. Keep this in place. -- **sing-box needs a config before starting** — apply config via the web UI first; it won't bootstrap empty. -- **No test suite exists** — validate changes manually via Docker. -- **NestJS build required** — the Dockerfile runs `npm ci && npm run build` during image build. For local dev use `npm run start:dev`. diff --git a/.gitignore b/.gitignore index 169ec65..a7cadfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,60 +1,24 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +# Local archive with the previous implementation and runtime secrets +_archive/ -# Virtual Env -venv/ -.venv/ -env/ +# Runtime state .env +*.env.local +data/ +.vpn-proxy/ -# PyInstaller -*.manifest -*.spec +# Node/Vite +node_modules/ +dist/ +coverage/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* -# MacOS +# OS/editors .DS_Store -.AppleDouble -.LSOverride -._* -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# IDE & Editors .idea/ .vscode/ *.swp *.swo -*~ - -# Project Specific -data/ -_legacy/ -*.log -sing-box - -# Docker -docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d65ac55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM node:22-bookworm-slim AS ui-build +WORKDIR /app +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 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \ + && 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=ui-build /app/dist /app/dist +COPY src/server /app/src/server +COPY entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh \ + && mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box + +ENV PORT=3456 \ + PROXY_PORT=8080 \ + TPROXY_PORT=7895 \ + DATA_DIR=/var/lib/vpn-proxy \ + SING_BOX_CONFIG=/etc/sing-box/config.json \ + SING_BOX_CACHE=/var/lib/sing-box/cache.db + +ENTRYPOINT ["dumb-init", "/entrypoint.sh"] diff --git a/README.md b/README.md index e22d0f2..2554425 100644 --- a/README.md +++ b/README.md @@ -1,363 +1,34 @@ -# 🌐 VPN Proxy — Домашний VPN в одной программе +# VPN Proxy Gateway -> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях. +Новая версия проекта начинается с `gateway`-режима: контейнер поднимается в `network_mode: host`, применяет TProxy-правила на хосте и запускает `sing-box` как прозрачный gateway для устройств в локальной сети. ---- +## Что уже заложено -## 📖 Что это такое? +- 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`; + - private IP ranges напрямую; + - RU rule sets напрямую; + - остальное через выбранный outbound. +- Docker entrypoint с idempotent TProxy setup/cleanup. -Это набор инструментов, который позволяет: +## Быстрый старт -1. **Запустить VPN-прокси** на вашем компьютере -2. **Управлять через удобное меню** — всё настраивается автоматически -3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси -4. **Работает с UDP** — голосовые звонки и игры тоже работают! - -### 🎯 Для кого это? - -- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам -- Разработчики, которые хотят направить трафик VS Code или других программ через VPN -- Геймеры, которым нужно запустить игры или Discord через VPN -- Люди, которые получили VLESS ссылку от VPN-провайдера - ---- - -## 🧩 Как это работает? - -``` -┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет -│ или Discord │ │ (порт 1080) │ │ (в другой стране)│ -└─────────────────┘ └──────────────────┘ └──────────────────┘ +```bash +cp .env.example .env +docker compose -f docker-compose.gateway.yml up -d --build ``` ---- +UI будет доступен на хосте по `http://:3456`. -## 🔧 Перед началом: Требования +## Важные ограничения v0.1 -### ✅ PowerShell 7 (Обязательно!) - -> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!** - -#### Проверьте вашу версию - -Откройте любой PowerShell и выполните: - -```powershell -$PSVersionTable.PSVersion.Major -``` - -- Если результат **7 или выше** — всё хорошо, переходите к установке ✅ -- Если **5 или ниже** — нужно установить PowerShell 7 👇 - -#### Установка PowerShell 7 - -**Способ 1: Через winget (самый простой)** - -Откройте обычный PowerShell или Командную строку и выполните: - -```powershell -winget install Microsoft.PowerShell -``` - -После установки закройте окно и откройте **PowerShell 7** (он появится в меню Пуск). - -**Способ 2: Скачать вручную** - -1. Перейдите: https://github.com/PowerShell/PowerShell/releases/latest -2. Скачайте файл `PowerShell-7.x.x-win-x64.msi` (где x.x.x — версия) -3. Запустите установщик и следуйте инструкциям -4. После установки используйте **PowerShell 7** из меню Пуск - -> 💡 **Как отличить?** PowerShell 7 имеет чёрный фон и надпись "pwsh" или "PowerShell 7". Старый PowerShell — синий фон. - ---- - -### ✅ URL Подписки или VLESS-ссылка - -Получите от вашего VPN-провайдера: - -- **Подписку**: URL, который начинается с `http://` или `https://` -- **VLESS-ссылку**: начинается с `vless://...` - ---- - -## 🚀 Установка на Windows - -### ⚡ Быстрая установка (Одной командой) - -Самый быстрый способ — использовать наш автоматический установщик. Он сам скачает проект и распакует его в `C:\Tools\vpn-proxy`. - -1. Откройте **PowerShell 7** от имени **Администратора** -2. Скопируйте и вставьте команду: - -```powershell -Set-ExecutionPolicy RemoteSigned -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex -``` - -> 💡 Если команда выдаст ошибку 404, попробуйте заменить `master` на `main` в ссылке, или используйте ручную установку ниже. - ---- - -### 📦 Ручная установка (если авто-установка не работает) - -Если вы предпочитаете всё делать сами: - -#### Шаг 1: Скачайте проект - -Мы рекомендуем использовать папку `C:\Tools`. - -```powershell -# 1. Создаем папку и переходим -New-Item -ItemType Directory -Force -Path "C:\Tools" | Out-Null -cd C:\Tools - -# 2. Клонируем или скачиваем архив -git clone https://git.dokops.ru/dokril/vpn-proxy - -# (Или скачайте ZIP вручную и распакуйте в C:\Tools\vpn-proxy) -``` - -#### Шаг 2: Запустите - -```powershell -cd C:\Tools\vpn-proxy -.\manage.ps1 -``` - -### Шаг 3: Выберите пункт [1] — VPN Клиент - -``` - [1] 📦 VPN Клиент (Sing-box) [НЕ УСТАНОВЛЕН] - Основной способ. Поддерживает UDP и игры. - - [2] 🎮 Настройка Discord/Vesktop [НЕ АКТИВЕН] - Маршрутизация приложений через прокси. - - --------------------------------------- - [3] 🔄 Обновить статус - [U] ❌ Удалить всё (Uninstall) - [q] Выход - -👉 Ваш выбор: 1 -``` - -### Шаг 4: Введите VLESS-ссылку или URL подписки - -Скрипт попросит ввести ссылку. Вставьте и нажмите Enter. - -**Готово!** 🎉 Прокси запущен на `127.0.0.1:1080` - -### 📂 Где всё хранится? - -Всё организовано в папке `C:\Tools`: - -1. **Сам проект:** `C:\Tools\vpn-proxy` - - `scripts/` — Скрипты управления (PowerShell) - - `web/` — Веб-интерфейс (для Docker/Python запуска) - - `docker/` — Конфигурация контейнеров -2. **Sing-box (VPN клиент):** `C:\Tools\sing-box` - - Здесь лежит `config.json` с вашими настройками и сам исполняемый файл -3. **ProxiFyre (для Discord):** `C:\Program Files\ProxiFyre` (системная служба) - ---- - -## ✅ Проверка работы - -После установки меню покажет статус и адреса подключения: - -``` - [1] 📦 VPN Клиент (Sing-box) [РАБОТАЕТ] - Основной способ. Поддерживает UDP и игры. - - 📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ - ───────────────────────────── - Локально: 127.0.0.1:1080 - Из сети: - 192.168.1.100:1080 -``` - -### Проверка через терминал - -```powershell -# Без прокси — покажет ваш домашний IP -Invoke-WebRequest -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content - -# Через прокси — должен показать IP VPN-сервера -Invoke-WebRequest -Proxy "http://127.0.0.1:1080" -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content -``` - -Если IP-адреса разные — VPN работает! 🎉 - ---- - -## 🎮 Настройка Discord / Vesktop - -Discord не поддерживает системные настройки прокси, поэтому нужна дополнительная настройка. - -### Требования - -- ✅ Установленный VPN клиент (пункт [1] в меню) -- ✅ VPN клиент должен быть запущен (статус "РАБОТАЕТ") - -### Установка - -1. Запустите `.\manage.ps1` -2. Выберите пункт **[2] — Настройка Discord/Vesktop** -3. Выберите какое приложение настроить: - - Discord - - Vesktop - - Оба - -**Что устанавливается:** - -- Windows Packet Filter — драйвер для перехвата трафика -- ProxiFyre — служба, которая направляет трафик Discord через прокси - -После установки Discord/Vesktop будут автоматически работать через VPN! - ---- - -## ⚙️ Настройка приложений - -### Для VS Code - -Откройте настройки (Ctrl + ,), найдите "proxy" и добавьте: - -``` -http.proxy: http://127.0.0.1:1080 -``` - -Или добавьте в `settings.json`: - -```json -{ - "http.proxy": "http://127.0.0.1:1080", - "http.proxyStrictSSL": true -} -``` - -### Для браузера - -В настройках прокси вашего браузера укажите: - -- **Тип**: HTTP или SOCKS5 -- **Адрес**: `127.0.0.1` -- **Порт**: `1080` - -> 💡 **Совет:** Используйте расширение [Proxy SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif) для удобного переключения прокси в Chrome. - -### Для других программ - -Укажите SOCKS5 прокси: `127.0.0.1:1080` - ---- - -## 📋 Управление - -При повторном запуске `.\manage.ps1` скрипт покажет меню управления: - -| Действие | Как сделать | -| ----------------- | ------------------------------------ | -| Посмотреть статус | Запустить `.\manage.ps1` | -| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" | -| Перезапустить | Пункт [1] → "Перезапустить" | -| Остановить | Пункт [1] → "Остановить" | -| Полностью удалить | Пункт [U] | - ---- - -## 🌍 Подключение из локальной сети - -Если вы хотите использовать прокси с других устройств (телефон, планшет): - -1. Посмотрите IP-адрес в меню (раздел "Из сети:") -2. На другом устройстве настройте прокси: `IP_ВАШЕГО_ПК:1080` - -Например: `192.168.1.100:1080` - ---- - -## ❓ Часто задаваемые вопросы - -### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено" - -**Решение:** Включите выполнение скриптов: - -```powershell -Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -``` - -### Ошибка при запуске — непонятные символы или синтаксис - -**Причина:** Вы используете старый PowerShell 5.1 - -**Решение:** Установите PowerShell 7 (см. раздел "Перед началом") - -### Discord не подключается к голосовым каналам - -**Причина:** ProxiFyre не запущен или VPN клиент остановлен - -**Решение:** - -1. Запустите `.\manage.ps1` -2. Убедитесь что пункт [1] показывает "РАБОТАЕТ" -3. Убедитесь что пункт [2] показывает "АКТИВЕН" - -### Как узнать, работает ли VPN? - -1. Откройте https://ipinfo.io в браузере — это ваш реальный IP -2. Настройте прокси в браузере -3. Откройте https://ipinfo.io снова — должен показать другой IP - ---- - -## 🔧 Продвинутые варианты - -### Docker с веб-интерфейсом - -Если вы предпочитаете управлять через браузер с красивым интерфейсом: - -> 💡 **Порты Docker версии:** -> -> - **Веб-интерфейс:** `http://localhost:3456` -> - **Прокси:** `127.0.0.1:8080` (обратите внимание, отличается от нативной версии!) - -> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**! -> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню). - -📖 **[Инструкция по Docker](docs/DOCKER.md)** - -### Установка на удалённый сервер (VPS) - -Если вы хотите развернуть прокси на своём сервере в другой стране: - -📖 **[Инструкция по установке на сервер](docs/SERVER.md)** - ---- - -## 📚 Словарь терминов - -| Термин | Объяснение | -| ------------ | ----------------------------------------------------------------------------- | -| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени | -| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером | -| **VLESS** | Современный протокол VPN-соединения | -| **sing-box** | Программа-клиент для подключения к VPN | -| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) | -| **Порт** | "Номер двери" для сетевых соединений | - ---- - -## 🆘 Нужна помощь? - -Если что-то не работает: - -1. Убедитесь что используете **PowerShell 7** -2. Запустите от имени **Администратора** -3. Проверьте статус в главном меню -4. Попробуйте переустановить: пункт [U], затем пункт [1] - ---- - -_Создано для простого и безопасного доступа в интернет_ 🛡️ +- IPv4 TProxy first. IPv6 routing будет отдельным этапом. +- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать клиентам DNS через роутер/DHCP. +- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`. +- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state. +- Gateway не видит process name на клиентском ПК, поэтому правила для игр задаются через домены, suffix, IP CIDR и порты. diff --git a/docker-compose.gateway.yml b/docker-compose.gateway.yml new file mode 100644 index 0000000..0152b5a --- /dev/null +++ b/docker-compose.gateway.yml @@ -0,0 +1,24 @@ +services: + vpn-proxy-gateway: + build: + context: . + dockerfile: Dockerfile + 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: diff --git a/docker-compose.server.yml b/docker-compose.server.yml deleted file mode 100644 index dcf681d..0000000 --- a/docker-compose.server.yml +++ /dev/null @@ -1,40 +0,0 @@ -# ========================================== -# СЕРВЕРНАЯ КОНФИГУРАЦИЯ (Linux VPS) -# ========================================== -# Используйте этот файл на удалённом сервере: -# docker compose -f docker-compose.server.yml up -d -# -# network_mode: host решает проблему UDP ASSOCIATE -# для SOCKS5 прокси (важно для Discord голоса!) -# ========================================== - -version: "3.9" -services: - sing-proxy: - container_name: sing-proxy - image: ${REGISTRY_HOST:-192.168.50.109:3000}/dokril/vpn-proxy/sing-proxy:latest - - # HOST MODE — контейнер использует сеть хоста напрямую - # Это решает проблему UDP ASSOCIATE для SOCKS5 - # ВАЖНО: работает только на Linux, не на Windows/macOS! - network_mode: host - - environment: - # Порт веб-интерфейса (по умолчанию 3456) - - PORT=${PORT:-3456} - # Порт прокси HTTP/SOCKS5 (по умолчанию 8080) - - PROXY_PORT=${PROXY_PORT:-8080} - - volumes: - - ./data:/app/data - restart: unless-stopped - deploy: - resources: - limits: - memory: 256m - -# Порты при network_mode: host не нужны. -# Сервисы доступны напрямую на хосте: -# - 3456: Веб-интерфейс (PORT) -# - 8080: SOCKS5/HTTP прокси (PROXY_PORT) - diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 06ec541..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: "3.9" -services: - sing-proxy: - build: - context: . - dockerfile: docker/Dockerfile.singbox - ports: - # Веб-интерфейс (можно переопределить: PORT=9090 docker compose up) - - "${PORT:-3456}:${PORT:-3456}" - # Прокси HTTP/SOCKS5 (можно переопределить: PROXY_PORT=8082 docker compose up) - - "${PROXY_PORT:-8080}:${PROXY_PORT:-8080}" - environment: - - PORT=${PORT:-3456} - - PROXY_PORT=${PROXY_PORT:-8080} - volumes: - - ./data:/app/data - restart: unless-stopped - deploy: - resources: - limits: - memory: 256m diff --git a/docker/Dockerfile.singbox b/docker/Dockerfile.singbox deleted file mode 100644 index 232f172..0000000 --- a/docker/Dockerfile.singbox +++ /dev/null @@ -1,36 +0,0 @@ -FROM alpine:3.20 -ARG SINGBOX_VER=1.12.13 - -# Устанавливаем зависимости, включая dos2unix для исправления скриптов -RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd nodejs npm dos2unix && update-ca-certificates - -# Устанавливаем pnpm -RUN npm install -g pnpm - -# Автоматическое определение архитектуры и установка sing-box -RUN ARCH=$(uname -m) && \ - if [ "$ARCH" = "x86_64" ]; then SB_ARCH="amd64"; \ - elif [ "$ARCH" = "aarch64" ]; then SB_ARCH="arm64"; \ - else SB_ARCH="amd64"; fi && \ - curl -L -o /tmp/sb.tar.gz https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VER}/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}.tar.gz \ - && tar -xf /tmp/sb.tar.gz -C /tmp \ - && mv /tmp/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}/sing-box /usr/local/bin/sing-box \ - && chmod +x /usr/local/bin/sing-box \ - && adduser -D -u 1000 suser - -COPY --chown=suser:suser docker/entrypoint.sh /app/ -COPY --chown=suser:suser web/ /app/web/ - -# Собираем NestJS бэкенд -WORKDIR /app/web/api -RUN CI=true pnpm install --frozen-lockfile && pnpm run build && CI=true pnpm prune --prod -WORKDIR /app - -# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск -RUN dos2unix /app/*.sh && chmod +x /app/entrypoint.sh - -# Порты по умолчанию (можно переопределить через ENV) -# PORT - веб-интерфейс, PROXY_PORT - прокси -EXPOSE 3456 8080 9090 - -ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100644 index 024dcd1..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -set -e - -CONFIG_FILE="/app/data/client.json" -SINGBOX_PID="" - -# Порты из ENV (по умолчанию: 3456 для веба, 8080 для прокси) -PORT="${PORT:-3456}" -PROXY_PORT="${PROXY_PORT:-8080}" - -# Ensure data directory exists -mkdir -p /app/data - -start_singbox() { - if [[ -f "$CONFIG_FILE" ]]; then - echo "$(date): Starting sing-box..." - sing-box run -c "$CONFIG_FILE" & - SINGBOX_PID=$! - echo "$(date): sing-box started with PID $SINGBOX_PID" - else - echo "$(date): Config file not found. Use web UI at :$PORT to apply config." - SINGBOX_PID="" - fi -} - -stop_singbox() { - if [[ -n "$SINGBOX_PID" ]]; then - echo "$(date): Stopping sing-box (PID $SINGBOX_PID)..." - kill "$SINGBOX_PID" 2>/dev/null || true - wait "$SINGBOX_PID" 2>/dev/null || true - SINGBOX_PID="" - fi -} - -restart_singbox() { - stop_singbox - start_singbox -} - -start_singbox - -# Start Web UI Server with configurable port -echo "$(date): Starting Web UI on port $PORT..." -PORT=$PORT PROXY_PORT=$PROXY_PORT node /app/web/api/dist/main.js & -WEBUI_PID=$! - -# HTTP Control Server (Simple Netcat loop) -# Listens on 9090. -# Endpoint: /reload -> Restart sing-box (used by web_server.py after config change) -( - while true; do - # Read the request using nc. - REQ=$(echo -e "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" | nc -l -p 9090 -q 1) - echo "$(date): Received request on 9090" - - if echo "$REQ" | grep -q "GET /reload"; then - echo "$(date): Action: RELOAD (Restart sing-box)" - restart_singbox - else - echo "$(date): Unknown request or ping." - fi - done -) & -CONTROL_PID=$! - -# Keep container alive - wait for any background process -echo "$(date): Entrypoint ready. Waiting for processes..." - -# Wait indefinitely - if WebUI dies, restart container -wait $WEBUI_PID diff --git a/docs/DOCKER.md b/docs/DOCKER.md deleted file mode 100644 index 146aee0..0000000 --- a/docs/DOCKER.md +++ /dev/null @@ -1,208 +0,0 @@ -# 🐳 Docker — Веб-интерфейс для управления VPN - -> **Это продвинутый способ** установки с красивым веб-интерфейсом. Для большинства пользователей рекомендуется использовать [основной способ через PowerShell](../README.md). - ---- - -## 📖 Что это даёт? - -- 🌐 **Веб-интерфейс** — управление через браузер на http://localhost:3456 -- 📡 **Подписки** — автоматическое получение списка серверов -- 🔄 **Переключение серверов** — в один клик -- 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются - ---- - -## 🔧 Требования - -### Docker Desktop - -1. Скачайте: https://www.docker.com/products/docker-desktop/ -2. Установите и запустите -3. Убедитесь, что иконка 🐳 есть в трее (панель задач) - -> 💡 На Windows может потребоваться WSL2. Docker Desktop предложит его установить автоматически. - ---- - -## 🚀 Установка - -### Шаг 1: Откройте терминал - -Откройте PowerShell или Командную строку и перейдите в папку проекта: - -```powershell -cd путь\к\папке\vpn-proxy -``` - -### Шаг 2: Соберите контейнер - -```powershell -docker compose build -``` - -Это создаст образ со всеми необходимыми компонентами. Выполняется один раз. - -### Шаг 3: Запустите - -```powershell -docker compose up -d -``` - -Флаг `-d` запускает контейнер в фоновом режиме. - -### Шаг 4: Откройте веб-интерфейс - -Перейдите в браузере: **http://localhost:3456** - ---- - -## 🌐 Использование веб-интерфейса - -### Режим подписки - -1. Вставьте URL подписки в поле "Подписка" -2. Нажмите **"Загрузить серверы"** -3. Выберите сервер из списка -4. Нажмите **"Применить"** - -### Режим VLESS - -1. Перейдите на вкладку "VLESS Ключ" -2. Вставьте VLESS-ссылку (`vless://...`) -3. Нажмите **"Применить"** - -> 💡 Настройки сохраняются в папке `data/` и восстанавливаются при перезапуске. - ---- - -## 🌐 Порты - -| Порт | Назначение | URL | -| ------ | --------------------------- | --------------------- | -| `3456` | Веб-интерфейс | http://localhost:3456 | -| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` | -| `9090` | API управления (внутренний) | — | - -### 🔧 Изменение порта прокси - -Если порт `8080` уже занят, можно запустить на другом порту (например, `8082`): - -**Способ 1: Через переменную окружения (Mac/Linux)** - -```bash -PROXY_PORT=8082 docker compose up -d -``` - -**Способ 2: Через переменную окружения (Windows PowerShell)** - -```powershell -$env:PROXY_PORT=8082; docker compose up -d -``` - -**Способ 3: Через .env файл (универсальный)** - -Создайте файл `.env` в корне проекта: - -``` -PROXY_PORT=8082 -``` - -Затем запустите: - -```bash -docker compose up -d -``` - -> 💡 URL подключения изменится на `http://127.0.0.1:8082` и `socks5://127.0.0.1:8082` - ---- - -## 📋 Управление контейнером - -| Действие | Команда | -| ----------------- | ---------------------------------- | -| Посмотреть статус | `docker ps` | -| Посмотреть логи | `docker logs --tail 50 sing-proxy` | -| Остановить | `docker compose stop` | -| Запустить снова | `docker compose start` | -| Перезапустить | `docker compose restart` | -| Полностью удалить | `docker compose down` | -| Пересобрать | `docker compose up -d --build` | - ---- - -## 🔄 Обновление - -Если вы обновили код из репозитория: - -```powershell -# Остановить текущий контейнер -docker compose down - -# Пересобрать с новыми изменениями -docker compose build --no-cache - -# Запустить заново -docker compose up -d -``` - -> 💡 Подписка и настройки сохраняются в папке `data/` и не потеряются. - ---- - -## ⚙️ Настройка приложений - -### Для VS Code - -```json -{ - "http.proxy": "http://127.0.0.1:8080", - "http.proxyStrictSSL": true -} -``` - -### Для браузера - -- **Адрес**: `127.0.0.1` -- **Порт**: `8080` -- **Тип**: HTTP или SOCKS5 - ---- - -## ❓ Проблемы и решения - -### Страница localhost:3456 не открывается - -**Причина:** Контейнер не запущен. - -```powershell -# Проверьте статус -docker ps - -# Если контейнера нет — запустите -docker compose up -d -``` - -### "Connection refused" - -**Причина:** VPN-ссылка не применена. - -1. Откройте http://localhost:3456 -2. Примените VLESS-ссылку или загрузите подписку - -### Медленное подключение - -Попробуйте другой сервер в веб-интерфейсе — некоторые серверы могут быть перегружены. - ---- - -## ⚠️ Ограничения Docker на Windows - -- **UDP для Discord:** Docker на Windows/macOS имеет проблемы с UDP ASSOCIATE для SOCKS5. Для Discord рекомендуется использовать [нативную установку](../README.md). - -- **Для полной поддержки UDP** используйте [установку на Linux сервер](SERVER.md) с `network_mode: host`. - ---- - -[← Вернуться к основной инструкции](../README.md) diff --git a/docs/SERVER.md b/docs/SERVER.md deleted file mode 100644 index c89ad3d..0000000 --- a/docs/SERVER.md +++ /dev/null @@ -1,278 +0,0 @@ -# 🌍 Установка на Сервер (Linux VPS) - -> Эта инструкция для установки прокси на удалённый сервер. После установки вы сможете подключаться к нему с любого устройства. - ---- - -## 📖 Зачем это нужно? - -- 🌐 **Один прокси для всех устройств** — компьютер, телефон, планшет -- 🔒 **Работает 24/7** — не нужно держать компьютер включённым -- 📡 **Полная поддержка UDP** — голосовые звонки и игры работают отлично -- 🏠 **Доступ из любого места** — дома, на работе, в поездке - ---- - -## 🔧 Требования к серверу - -- **ОС:** Ubuntu 20.04+, Debian 11+, или любой современный Linux -- **Ресурсы:** Минимум 512 MB RAM, 1 CPU -- **Порты:** 3456 (веб-интерфейс), 8080 (прокси) -- **Доступ:** SSH подключение - -> 💡 Подойдёт любой VPS за $3-5/месяц от DigitalOcean, Vultr, Hetzner и др. - ---- - -## 🚀 Установка - -### Шаг 1: Подключитесь к серверу - -Откройте терминал (PowerShell на Windows, Terminal на Mac/Linux): - -```bash -ssh root@ваш_сервер_ip -``` - -Введите пароль когда попросят. - -> 💡 **Совет:** Если вы на Windows и нет ssh команды, используйте PuTTY или Windows Terminal. - ---- - -### Шаг 2: Установите Docker - -Если Docker ещё не установлен: - -```bash -# Автоматическая установка Docker -curl -fsSL https://get.docker.com | sh - -# Проверка что Docker работает -docker --version -``` - ---- - -### Шаг 3: Загрузите проект - -**Вариант A: Через Git** - -```bash -git clone https://github.com/your-repo/vpn-proxy.git -cd vpn-proxy -``` - -**Вариант B: Загрузка файлов вручную** - -Если git недоступен, скачайте ZIP архив и распакуйте на сервере. - ---- - -### Шаг 4: Запустите контейнер - -> ⚠️ **Важно:** Используйте `docker-compose.server.yml` — он настроен для серверов! - -```bash -docker compose -f docker-compose.server.yml up -d -``` - -Это запустит контейнер с `network_mode: host`, что решает проблемы с UDP. - ---- - -### Шаг 5: Откройте порты в файрволе - -**Для UFW (Ubuntu/Debian):** - -```bash -ufw allow 3456/tcp # Веб-интерфейс -ufw allow 8080/tcp # Прокси TCP -ufw allow 8080/udp # Прокси UDP (для голоса/игр) -ufw reload -``` - -**Для firewalld (CentOS/RHEL):** - -```bash -firewall-cmd --permanent --add-port=3456/tcp -firewall-cmd --permanent --add-port=8080/tcp -firewall-cmd --permanent --add-port=8080/udp -firewall-cmd --reload -``` - -**Для iptables:** - -```bash -iptables -A INPUT -p tcp --dport 3456 -j ACCEPT -iptables -A INPUT -p tcp --dport 8080 -j ACCEPT -iptables -A INPUT -p udp --dport 8080 -j ACCEPT -``` - ---- - -### Шаг 6: Настройте VPN через веб-интерфейс - -1. Откройте в браузере: `http://ваш_сервер_ip:3456` -2. Вставьте VLESS-ссылку или URL подписки -3. Нажмите "Применить" - ---- - -## ✅ Проверка работы - -На сервере: - -```bash -# Проверить что контейнер запущен -docker ps - -# Посмотреть логи -docker logs --tail 20 sing-proxy -``` - -С вашего компьютера: - -```bash -# Проверить прокси -curl -x http://ваш_сервер_ip:8080 https://ipinfo.io/ip -``` - -Должен показать IP VPN-сервера (не IP вашего VPS). - ---- - -## 🖥️ Подключение с Windows - -### Настройка в manage.ps1 - -При настройке Discord (пункт [2]) вы можете указать адрес удалённого прокси: - -``` -Введите адрес прокси (IP:порт): ваш_сервер_ip:8080 -``` - -### Настройка в браузере/приложениях - -- **Адрес:** `ваш_сервер_ip` -- **Порт:** `8080` -- **Тип:** HTTP или SOCKS5 - ---- - -## 📋 Управление - -| Действие | Команда | -|----------|---------| -| Посмотреть статус | `docker ps` | -| Логи | `docker logs --tail 50 sing-proxy` | -| Остановить | `docker compose -f docker-compose.server.yml stop` | -| Запустить | `docker compose -f docker-compose.server.yml start` | -| Перезапустить | `docker compose -f docker-compose.server.yml restart` | -| Удалить | `docker compose -f docker-compose.server.yml down` | - ---- - -## 🔐 Рекомендации по безопасности - -### 1. Смените стандартные порты - -Отредактируйте `docker-compose.server.yml`: - -```yaml -environment: - - PORT=54321 # Вместо 3456 - - PROXY_PORT=12345 # Вместо 8080 -``` - -### 2. Ограничьте доступ к веб-интерфейсу - -Если веб-интерфейс нужен только для первоначальной настройки: - -```bash -# Закрыть веб-порт после настройки -ufw delete allow 3456/tcp -``` - -### 3. Используйте SSH туннель - -Для безопасного доступа к веб-интерфейсу: - -```bash -ssh -L 3456:localhost:3456 root@ваш_сервер_ip -``` - -Затем откройте http://localhost:3456 в браузере. - ---- - -## 🔄 Обновление - -```bash -cd vpn-proxy - -# Получить обновления -git pull - -# Пересобрать контейнер -docker compose -f docker-compose.server.yml down -docker compose -f docker-compose.server.yml build --no-cache -docker compose -f docker-compose.server.yml up -d -``` - ---- - -## ❓ Проблемы и решения - -### Порт 3456 не открывается - -**Причина:** Файрвол блокирует подключения. - -**Решение:** Проверьте настройки файрвола, см. Шаг 5. - -### "Permission denied" при запуске Docker - -**Решение:** - -```bash -# Добавить пользователя в группу docker -sudo usermod -aG docker $USER - -# Перезайти -exit -ssh root@ваш_сервер_ip -``` - -### Контейнер постоянно перезапускается - -```bash -# Посмотреть логи ошибок -docker logs sing-proxy -``` - -Обычно проблема в неверной VLESS-ссылке. - ---- - -## 📐 Изменение портов - -По умолчанию: -- **3456** — веб-интерфейс -- **8080** — прокси - -Для изменения создайте файл `.env` в папке проекта: - -```env -PORT=54321 -PROXY_PORT=12345 -``` - -И перезапустите: - -```bash -docker compose -f docker-compose.server.yml up -d -``` - ---- - -[← Вернуться к основной инструкции](../README.md) diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..a272789 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,105 @@ +# Roadmap: VPN Proxy rebuild + +## Целевая модель + +Проект должен стать multi-mode системой вокруг `sing-box`: + +| Режим | Назначение | Runtime | Статус | +| --- | --- | --- | --- | +| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым | +| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации | +| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок | + +## Gateway mode + +Цель: контейнер, который становится прозрачным gateway для сети. + +Требования: + +- `sing-box` внутри контейнера. +- `network_mode: host`. +- `CAP_NET_ADMIN` и `CAP_NET_RAW`. +- TProxy inbound на `7895`. +- Mixed HTTP/SOCKS inbound на `8080`. +- Web UI на `3456`. +- Subscription URL вводится в UI, парсится, пользователь выбирает сервер. +- Пользовательские routing lists управляются из UI. +- Генерируется `/etc/sing-box/config.json`. +- `sing-box check` перед применением. +- Restart `sing-box` после применения. +- Idempotent iptables setup. +- Cleanup iptables/ip rule/ip route при остановке контейнера. + +Маршрутизация v1: + +- private IP ranges -> `direct`. +- пользовательские списки -> `direct`, `vpn` или `block`. +- `geoip-ru` -> `direct`. +- `geosite-category-ru` -> `direct`. +- все остальное -> выбранный VPN outbound. + +Порядок правил: + +1. safety private-direct, чтобы не ломать LAN. +2. custom routing lists из UI. +3. RU direct rules. +4. default VPN outbound. + +Формат пользовательского списка: + +- `name`. +- `enabled`. +- `outbound`: `direct`, `vpn`, `block`. +- `domains`: exact domains. +- `domainSuffixes`: доменные suffix, удобно для игр/сервисов. +- `domainKeywords`: keyword matching. +- `ipCidrs`: CIDR ranges. +- `ports`: TCP/UDP ports. +- `networks`: `tcp`, `udp`. +- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование. + +Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса. + +Отдельно решить позже: + +- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound. +- IPv6 TProxy. +- nftables backend. +- health checks и smoke diagnostics. +- secret storage через Infisical/Vault/env. + +## Desktop proxy mode + +Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy. + +Требования: + +- UI на `3456`. +- Mixed inbound на `8080`. +- Subscription parser. +- Выбор сервера. +- Fallback proxy через `urltest`. +- Direct mode toggle. +- Не требует `NET_ADMIN`. + +## Windows gaming mode + +Цель: сохранить сценарий для Discord/Vesktop/игр. + +Требования: + +- Native `sing-box.exe`. +- Scheduled task или Windows service. +- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy. +- Управление из PowerShell helper. +- Позже можно сделать Electron/Tauri UI поверх privileged helper. + +## Рабочий порядок + +1. Сделать новый gateway root. +2. Реализовать Docker image + entrypoint TProxy lifecycle. +3. Реализовать маленький control-server. +4. Реализовать Vite + React UI для subscription -> server select -> apply. +5. Добавить gateway docs/install script. +6. Потом переносить desktop-proxy. +7. Потом приводить Windows mode к новой архитектуре. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5f690db --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +TPROXY_PORT="${TPROXY_PORT:-7895}" +TPROXY_MARK="${TPROXY_MARK:-1}" +TPROXY_TABLE="${TPROXY_TABLE:-100}" +TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" +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}" + +log() { + printf '[gateway-entrypoint] %s\n' "$*" +} + +ipt() { + iptables -w "$@" +} + +cleanup_tproxy() { + log "cleanup tproxy rules" + ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true + ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true + ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true + ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true + ip route flush table "$TPROXY_TABLE" 2>/dev/null || true +} + +setup_tproxy() { + log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}" + cleanup_tproxy + + ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true + ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE" + + ipt -t mangle -N "$TPROXY_CHAIN" + ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN + + for cidr in $BYPASS_CIDRS; do + ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN + done + + ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" + ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" + ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN" +} + +setup_tproxy + +node /app/src/server/index.js & +APP_PID=$! + +shutdown() { + log "shutdown requested" + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + cleanup_tproxy +} + +trap 'shutdown; exit 0' SIGTERM SIGINT + +wait "$APP_PID" +STATUS=$? +cleanup_tproxy +exit "$STATUS" diff --git a/index.html b/index.html new file mode 100644 index 0000000..c596d96 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + VPN Proxy Gateway + + +
+ + + diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 4e90d3f..0000000 --- a/install.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -# ========================================== -# 🚀 VPN PROXY INSTALLER -# ========================================== -# This script automatically downloads and installs VPN Proxy -# Usage: -# iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex - -# Enable UTF-8 for emoji support -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$ErrorActionPreference = "Stop" - -# --- 1. Check Admin Rights --- -if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) { - Write-Warning "⚠️ Administrator rights required!" - Write-Host "🔄 Restarting script as Administrator..." -ForegroundColor Cyan - - # Save script to temp file if running from memory (iex) - if ($MyInvocation.MyCommand.CommandType -eq 'Script') { - Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs - } - else { - # If running via IEX, we cannot simple restart the file. - # We ask user to run terminal as admin. - Write-Error "Please run PowerShell as Administrator and try again." - } - exit -} - -# --- 2. Settings --- -$InstallRoot = "C:\Tools" -$InstallDir = "$InstallRoot\vpn-proxy" -# Exact link provided by user -$ZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip" -$TempZip = "$env:TEMP\vpn-proxy-install.zip" - -Write-Host "🚀 Starting VPN Proxy installation..." -ForegroundColor Green -Write-Host "📂 Install path: $InstallDir" -ForegroundColor Gray - -# Move to temp folder to avoid blocking deletion if we are already in C:\Tools\vpn-proxy -Set-Location $env:TEMP - -# --- 3. Prepare Directory --- -if (-not (Test-Path $InstallRoot)) { - New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null -} - -# --- 4. Downloading --- -Write-Host "⬇️ Downloading update archive..." -ForegroundColor Cyan -try { - Invoke-WebRequest -Uri $ZipUrl -OutFile $TempZip -} -catch { - Write-Error "❌ Failed to download from $ZipUrl`nCheck your internet connection." - exit 1 -} - -# --- 5. Extracting --- -Write-Host "📦 Extracting..." -ForegroundColor Cyan - -# If folder exists, delete old one -if (Test-Path $InstallDir) { - try { - Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop - } - catch { - Write-Warning "⚠️ Failed to delete old folder $InstallDir" - Write-Warning " Error: $($_.Exception.Message)" - Write-Warning " Make sure files are not open in other programs and you are not inside this folder." - - $retry = Read-Host " Press Enter to try again (or Ctrl+C to cancel)" - try { - Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop - } - catch { - Write-Error "❌ Still failed to delete folder. Installation aborted." - exit 1 - } - } -} - -Expand-Archive -Path $TempZip -DestinationPath $InstallRoot -Force - -# Archives usually extract to vpn-proxy-master or vpn-proxy-main -# We need to rename it to vpn-proxy -$ExtractedFolder = Get-ChildItem -Path $InstallRoot -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1 - -if ($ExtractedFolder) { - Rename-Item -Path $ExtractedFolder.FullName -NewName "vpn-proxy" -Force -} - -# Remove temp archive -Remove-Item $TempZip -Force - -if (-not (Test-Path "$InstallDir\manage.ps1")) { - Write-Error "❌ Installation error: manage.ps1 not found in $InstallDir" - exit 1 -} - -# --- 6. Finish --- -Write-Host "✅ Installation complete!" -ForegroundColor Green -Write-Host "" -Write-Host "To start the control menu, run:" -ForegroundColor Cyan -Write-Host "& `"$InstallDir\manage.ps1`"" -ForegroundColor Yellow -Write-Host "" diff --git a/manage.ps1 b/manage.ps1 deleted file mode 100644 index 7340a47..0000000 --- a/manage.ps1 +++ /dev/null @@ -1,96 +0,0 @@ -# ========================================== -# 🚀 VPN PROXY CONTROL CENTER (WINDOWS) -# ========================================== -# Главный скрипт управления. Запускать от имени Администратора. -# Использование: .\manage.ps1 [-Debug] - -param([switch]$Debug) - -$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } -$LibDir = "$ScriptDir\scripts\lib" - -# Проверка библиотек -if (!(Test-Path "$LibDir\Common.ps1")) { - Write-Host "❌ Ошибка: Не найдены библиотеки в $LibDir" -ForegroundColor Red - exit 1 -} - -. "$LibDir\Common.ps1" -. "$LibDir\System.ps1" - -# Установка режима отладки -if ($Debug) { - Set-DebugMode -Enabled $true -} - -Ensure-Admin - -while ($true) { - Write-Header "VPN PROXY CONTROL CENTER" -ClearScreen - - # --- СБОР СТАТУСОВ --- - - # 1. Native Sing-box - $sbStatus = Get-TaskStatus -Name "SingBoxProxy" - $sbStr = if ($sbStatus -eq "Running") { "РАБОТАЕТ" } else { "ОСТАНОВЛЕН" } - $sbColor = if ($sbStatus -eq "Running") { "Green" } else { "Yellow" } - if (!$sbStatus) { $sbStr = "НЕ УСТАНОВЛЕН"; $sbColor = "Gray" } - - # 2. Discord Proxy - $discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue - $discStr = if ($discSvc.Status -eq 'Running') { "АКТИВЕН" } else { "НЕ АКТИВЕН" } - $discColor = if ($discSvc.Status -eq 'Running') { "Green" } else { "Gray" } - - # --- ОТРИСОВКА МЕНЮ --- - - Write-Host " [1] 📦 VPN Клиент (Sing-box)" -NoNewline -ForegroundColor White - Write-Host " [$sbStr]" -ForegroundColor $sbColor - Write-Host " Основной способ. Поддерживает UDP и игры." -ForegroundColor Gray - - # Показываем информацию о подключении если sing-box работает - if ($sbStatus -eq "Running") { - $LocalProxyPort = 1080 - . "$LibDir\Net.ps1" - $ips = Get-LocalIPs - - Write-Host "" - Write-Host " 📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ" -ForegroundColor Cyan - Write-Host " ─────────────────────────────" -ForegroundColor DarkGray - Write-Host " Локально: " -NoNewline -ForegroundColor Gray - Write-Host "127.0.0.1:$LocalProxyPort" -ForegroundColor Green - - if ($ips) { - Write-Host " Из сети:" -ForegroundColor Gray - foreach ($ip in $ips) { - Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Yellow - } - } - Write-Host "" - } - Write-Host "" - - Write-Host " [2] 🎮 Настройка Discord/Vesktop" -NoNewline -ForegroundColor White - Write-Host " [$discStr]" -ForegroundColor $discColor - Write-Host " Маршрутизация приложений через прокси." -ForegroundColor Gray - Write-Host "" - - Write-Host " ---------------------------------------" -ForegroundColor DarkGray - - - Write-Host " [3] 🔄 Обновить статус" -ForegroundColor White - Write-Host " [L] 📜 Просмотр логов" -ForegroundColor White - Write-Host " [U] ❌ Удалить всё (Uninstall)" -ForegroundColor Red - Write-Host " [q] Выход" -ForegroundColor White - Write-Host "" - - $choice = Read-Host "👉 Ваш выбор" - - switch ($choice) { - "1" { & "$ScriptDir\scripts\setup-singbox.ps1" } - "2" { & "$ScriptDir\scripts\setup-discord.ps1" } - "3" { continue } - "l" { & "$ScriptDir\scripts\view-logs.ps1" } - "u" { & "$ScriptDir\scripts\uninstall-all.ps1" } - "q" { exit } - } -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c4e95cc --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "vpn-proxy-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "start": "node src/server/index.js" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": {} +} diff --git a/scripts/lib/Common.ps1 b/scripts/lib/Common.ps1 deleted file mode 100644 index 0ed302b..0000000 --- a/scripts/lib/Common.ps1 +++ /dev/null @@ -1,118 +0,0 @@ -# ========================================== -# 🛠️ COMMON UTILS -# ========================================== - -# --- ГЛОБАЛЬНЫЕ НАСТРОЙКИ --- - -# Режим отладки (передаётся через -Debug) -if (-not (Test-Path variable:script:DebugMode)) { - $script:DebugMode = $false -} - -function Set-DebugMode { - param([bool]$Enabled) - $script:DebugMode = $Enabled - if ($Enabled) { - Write-Host " 🔧 Debug режим включён" -ForegroundColor Magenta - } -} - -function Get-DebugMode { - return $script:DebugMode -} - -# --- ЦВЕТА И ВЫВОД --- - -function Write-Step { param($msg) Write-Host "`n📦 $msg" -ForegroundColor Cyan } -function Write-Success { param($msg) Write-Host " ✅ $msg" -ForegroundColor Green } -function Write-Warning { param($msg) Write-Host " ⚠️ $msg" -ForegroundColor Yellow } -function Write-Error { param($msg) Write-Host " ❌ $msg" -ForegroundColor Red } -function Write-Info { param($msg) Write-Host " ℹ️ $msg" -ForegroundColor Gray } - -function Write-DebugLog { - param($msg) - if ($script:DebugMode) { - Write-Host " [DEBUG] $msg" -ForegroundColor DarkGray - } -} - -function Write-Header { - param($Title, [switch]$ClearScreen) - - if ($ClearScreen -and -not $script:DebugMode) { - Clear-Host - } - - Write-Host "" - Write-Host "==========================================" -ForegroundColor Cyan - Write-Host " $Title" -ForegroundColor Cyan - Write-Host "==========================================" -ForegroundColor Cyan - Write-Host "" -} - -# --- ЗАПУСК КОМАНД --- - -function Invoke-Silent { - param( - [string]$FilePath, - [string]$Arguments, - [switch]$Wait - ) - - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = $FilePath - $psi.Arguments = $Arguments - $psi.UseShellExecute = $false - $psi.CreateNoWindow = $true - - if (-not $script:DebugMode) { - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - } - - $process = [System.Diagnostics.Process]::Start($psi) - - if ($Wait) { - $process.WaitForExit() - return $process.ExitCode - } - - return $process -} - -# --- ПОЛЕЗНЫЕ ФУНКЦИИ --- - -function Get-ScriptDirectory { - if ($PSScriptRoot) { return $PSScriptRoot } - return Split-Path -Parent $MyInvocation.MyCommand.Path -} - -function Ensure-Admin { - $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") - if (-not $isAdmin) { - Write-Host "⛔ Требуются права АДМИНИСТРАТОРА!" -ForegroundColor Red - Write-Host " Пожалуйста, запустите скрипт от имени администратора." -ForegroundColor Gray - Start-Sleep -Seconds 3 - exit 1 - } -} - -function Show-Menu { - param( - [string]$Title, - [System.Collections.Specialized.OrderedDictionary]$Options, - [string]$Prompt = "👉 Ваш выбор" - ) - - if ($Title) { - Write-Host "`n$Title" -ForegroundColor Yellow - } - - $keys = $Options.Keys - foreach ($key in $keys) { - Write-Host " [$key] $($Options[$key])" -ForegroundColor White - } - Write-Host "" - - return Read-Host "$Prompt" -} diff --git a/scripts/lib/Net.ps1 b/scripts/lib/Net.ps1 deleted file mode 100644 index 74ca1a0..0000000 --- a/scripts/lib/Net.ps1 +++ /dev/null @@ -1,144 +0,0 @@ -# ========================================== -# 🌐 NET UTILS -# ========================================== - -# --- CONFIG --- -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -# --- ФУНКЦИИ --- - -$script:HwidFile = "C:\Tools\sing-box\hwid" -$script:AppName = "VPN-Proxy-Control by Dokril" - -function Get-HWID { - # Генерация или чтение HWID из файла - if (Test-Path $script:HwidFile) { - return (Get-Content $script:HwidFile -Raw).Trim() - } - - # Генерируем новый HWID - $hwid = [Guid]::NewGuid().ToString("N").Substring(0, 16) - - # Сохраняем - $dir = Split-Path $script:HwidFile -Parent - if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } - Set-Content -Path $script:HwidFile -Value $hwid - - return $hwid -} - -function Get-SubscriptionHeaders { - # Формируем заголовки как в server.py - $osName = "windows" - $osVersion = [Environment]::OSVersion.Version.ToString() - - return @{ - "User-Agent" = "singbox" - "x-hwid" = (Get-HWID) - "x-device-os" = $osName - "x-ver-os" = $osVersion - "x-device-model" = $script:AppName - } -} - -function Download-File { - param( - [string]$Url, - [string]$Destination, - [string]$UserAgent = "VPN-Proxy-Installer" - ) - - try { - $req = [System.Net.HttpWebRequest]::Create($Url) - $req.UserAgent = $UserAgent - $resp = $req.GetResponse() - - $stream = $resp.GetResponseStream() - $fs = [System.IO.File]::Create($Destination) - $msgLen = $resp.ContentLength - - $buffer = New-Object byte[] 10240 - $count = 0 - $total = 0 - - do { - $count = $stream.Read($buffer, 0, $buffer.Length) - $fs.Write($buffer, 0, $count) - $total += $count - # Можно добавить прогресс бар, но пока просто качаем - } while ($count -gt 0) - - $fs.Close() - $stream.Close() - $resp.Close() - - # Unblock file to prevent "Mark of the Web" issues - Unblock-File -Path $Destination -ErrorAction SilentlyContinue - - return $true - } - catch { - Write-Error "Ошибка скачивания: $_" - return $false - } -} - - -function Get-SubscriptionData { - param( - [string]$Url, - [string]$UserAgent = "singbox", - $Headers = @{} - ) - - Write-Info "Загружаю подписку..." - - $rawContent = $null - $userInfo = @{} - - # 1. Получаем ответ - try { - $response = Invoke-WebRequest -Uri $Url -Headers $Headers -TimeoutSec 15 -UseBasicParsing - $rawContent = $response.Content - - # Парсим subscription-userinfo header - $userInfoHeader = $response.Headers["subscription-userinfo"] - if ($userInfoHeader) { - $parts = $userInfoHeader -split ";" - foreach ($part in $parts) { - if ($part -match "(\w+)=(\d+)") { - $userInfo[$matches[1]] = [int64]$matches[2] - } - } - } - } - catch { - return @{ - success = $false - error = "Ошибка загрузки: $($_.Exception.Message)" - rawContent = $null - } - } - - # 2. Пробуем парсить как JSON - try { - $config = $rawContent | ConvertFrom-Json - return @{ - success = $true - config = $config - rawContent = $rawContent - userInfo = $userInfo - } - } - catch { - # JSON не распарсился — возвращаем rawContent для дальнейшей обработки - return @{ - success = $false - error = "Ответ не в формате JSON (возможно Base64 или список ссылок)" - rawContent = $rawContent - userInfo = $userInfo - } - } -} - - diff --git a/scripts/lib/System.ps1 b/scripts/lib/System.ps1 deleted file mode 100644 index 58c8795..0000000 --- a/scripts/lib/System.ps1 +++ /dev/null @@ -1,175 +0,0 @@ -# ========================================== -# 🖥️ SYSTEM UTILS -# ========================================== - - -# --- СИСТЕМНАЯ ИНФОРМАЦИЯ --- - -function Get-SystemInfo { - return @{ - os = "windows" - version = [System.Environment]::OSVersion.Version.Major.ToString() - } -} - -function Ensure-VCRedist { - Write-Info "Проверка Visual C++ Redistributable..." - - # Check registry for VC++ 2015-2022 (x64) - # Key: HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64 - $regPath = "HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" - if (Test-Path $regPath) { - $installed = (Get-ItemProperty -Path $regPath).Installed - if ($installed -eq 1) { - Write-Success "Visual C++ Redistributable уже установлен." - return - } - } - - Write-Warning "Visual C++ Redistributable не найден. Устанавливаю..." - - $vcUrl = "https://aka.ms/vs/17/release/vc_redist.x64.exe" - $vcFile = "$env:TEMP\vc_redist.x64.exe" - - if (Download-File -Url $vcUrl -Destination $vcFile) { - Write-Step "Установка библиотек Visual C++..." - $process = Start-Process -FilePath $vcFile -ArgumentList "/install", "/quiet", "/norestart" -PassThru -Wait - - if ($process.ExitCode -eq 0 -or $process.ExitCode -eq 3010) { - # 3010 = reboot required (usually works without immediate reboot) - Write-Success "Библиотеки установлены!" - } - else { - Write-Error "Ошибка установки VC++ (Код: $($process.ExitCode))" - Write-Host " Попробуйте установить вручную: https://aka.ms/vs/17/release/vc_redist.x64.exe" - } - - Remove-Item $vcFile -Force -ErrorAction SilentlyContinue - } -} - -# --- DOCKER --- - -function Test-Docker { - $status = @{ - Installed = $false - Running = $false - Compose = $false - } - - try { - $ver = docker --version 2>&1 - if ($LASTEXITCODE -eq 0) { $status.Installed = $true } - } - catch {} - - if ($status.Installed) { - try { - $info = docker info 2>&1 - if ($LASTEXITCODE -eq 0) { $status.Running = $true } - } - catch {} - } - - if ($status.Running) { - try { - $comp = docker compose version 2>&1 - if ($LASTEXITCODE -eq 0) { $status.Compose = $true } - } - catch { - # Check legacy - try { - $comp = docker-compose --version 2>&1 - if ($LASTEXITCODE -eq 0) { $status.Compose = $true } - } - catch {} - } - } - - return $status -} - -# --- СЛУЖБЫ И ЗАДАЧИ --- - -function Manage-ScheduledTask { - param( - [string]$Name, - [string]$ExePath, - [string]$Arguments, - [string]$WorkDir, - [string]$Action = "Install" # Install, Uninstall, Start, Stop - ) - - switch ($Action) { - "Install" { - # Удаляем старую - Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue - - $act = New-ScheduledTaskAction -Execute "$ExePath" -Argument "$Arguments" -WorkingDirectory $WorkDir - $trig = New-ScheduledTaskTrigger -AtStartup - $princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest - $sett = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) - - Register-ScheduledTask -TaskName $Name -Action $act -Trigger $trig -Principal $princ -Settings $sett -Force | Out-Null - return $true - } - "Uninstall" { - Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue - } - "Start" { - Start-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue - } - "Stop" { - Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue - # Пытаемся убить процесс по имени exe - if ($ExePath) { - $procName = [System.IO.Path]::GetFileNameWithoutExtension($ExePath) - if ($procName) { - Stop-Process -Name $procName -Force -ErrorAction SilentlyContinue - } - } - } - } -} - -function Get-TaskStatus { - param([string]$Name) - $task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue - if ($task) { - # Если задача в статусе Running — возвращаем Running - if ($task.State -eq "Running") { - return "Running" - } - - # Если задача Ready — проверяем, работает ли процесс sing-box - # (scheduled task может быть Ready даже когда процесс работает) - $process = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue - if ($process) { - return "Running" - } - - return $task.State - } - return $null -} - - - -function Ensure-FirewallPort { - param( - [int]$Port, - [string]$Name, - [string]$Protocol = "TCP" - ) - - $rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue - if (-not $rule) { - New-NetFirewallRule -DisplayName $Name -Direction Inbound -LocalPort $Port -Protocol $Protocol -Action Allow -Profile Any | Out-Null - return $true - } - return $false -} - -function Get-LocalIPs { - return (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias * | Where-Object { $_.IPAddress -notmatch "^127\." -and $_.IPAddress -notmatch "^169\.254\." }).IPAddress -} diff --git a/scripts/setup-discord.ps1 b/scripts/setup-discord.ps1 deleted file mode 100644 index 12bccac..0000000 --- a/scripts/setup-discord.ps1 +++ /dev/null @@ -1,420 +0,0 @@ -# ========================================== -# 🎮 DISCORD PROXY SETUP -# ========================================== - -param( - [switch]$Force, - [switch]$Debug -) - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. "$ScriptDir\lib\Common.ps1" -. "$ScriptDir\lib\Net.ps1" -. "$ScriptDir\lib\System.ps1" - -if ($Debug) { Set-DebugMode -Enabled $true } - -Write-Header "НАСТРОЙКА DISCORD / VESKTOP" -ClearScreen - -Ensure-Admin - -$InstallPath = "C:\Tools\ProxiFyre" -$ConfigPath = "$InstallPath\app-config.json" -$DriverUrl = "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi" -$AppUrl = "https://github.com/wiresock/proxifyre/releases/download/v2.1.4/ProxiFyre-v2.1.4-x64-signed.zip" - -# --- ФУНКЦИИ --- - -function Test-ProxyConnection { - param([string]$ProxyAddr) - - Write-Info "Проверка подключения к прокси $ProxyAddr..." - - try { - $parts = $ProxyAddr -split ":" - $host_ = $parts[0] - $port = [int]$parts[1] - - # 1. Проверяем TCP соединение - $tcp = New-Object System.Net.Sockets.TcpClient - $tcp.Connect($host_, $port) - $tcp.Close() - Write-Success "TCP соединение установлено" - - # 2. Пробуем получить внешний IP через прокси (используем curl для SOCKS5) - try { - $result = & curl.exe -s -x "socks5://$ProxyAddr" "http://v4.ident.me" --connect-timeout 5 2>$null - if ($result -match "^\d+\.\d+\.\d+\.\d+$") { - Write-Success "Внешний IP через прокси: $result" - return $true - } - } - catch {} - - Write-Warning "TCP работает, но не удалось получить IP. Возможно прокси не полностью настроен." - return $true - } - catch { - Write-Error "Не удалось подключиться к $ProxyAddr" - Write-Host " Убедитесь, что прокси запущен и доступен." -ForegroundColor Gray - return $false - } -} - -function Get-CurrentConfig { - if (Test-Path $ConfigPath) { - try { - $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json - return @{ - Apps = $cfg.proxies[0].appNames -join ", " - Proxy = $cfg.proxies[0].socks5ProxyEndpoint - } - } - catch {} - } - return $null -} - -function Install-ProxiFyre { - # 0. Остановка старых процессов (чтобы файлы не были заблокированы) - Write-Step "Остановка старых процессов..." - Stop-Service "ProxiFyreService" -Force -ErrorAction SilentlyContinue - Stop-Process -Name "ProxiFyre" -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 - - # Установка драйвера - Write-Step "Установка драйвера..." - $msi = "$env:TEMP\WinpkFilter.msi" - if (Download-File -Url $DriverUrl -Destination $msi) { - Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart" -Wait - Write-Success "Драйвер готов" - } - - # Установка ProxiFyre - Write-Step "Установка ProxiFyre..." - New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null - $zip = "$env:TEMP\ProxiFyre.zip" - if (Download-File -Url $AppUrl -Destination $zip) { - Expand-Archive -Path $zip -DestinationPath $InstallPath -Force - $exe = Get-ChildItem $InstallPath -Recurse -Filter "ProxiFyre.exe" | Select-Object -First 1 - if ($exe.DirectoryName -ne $InstallPath) { - Copy-Item "$($exe.DirectoryName)\*" $InstallPath -Recurse -Force - } - Write-Success "Распаковано" - } - - # Создание правил Firewall - Write-Step "Настройка Windows Firewall..." - $exePath = "$InstallPath\ProxiFyre.exe" - $ruleName = "ProxiFyre" - - # Удаляем старые правила - Remove-NetFirewallRule -DisplayName "$ruleName*" -ErrorAction SilentlyContinue - - # Входящее правило - New-NetFirewallRule -DisplayName "$ruleName (Inbound)" ` - -Direction Inbound ` - -Action Allow ` - -Program $exePath ` - -Profile Domain, Private, Public ` - -Description "Разрешить входящие соединения для ProxiFyre" | Out-Null - - # Исходящее правило - New-NetFirewallRule -DisplayName "$ruleName (Outbound)" ` - -Direction Outbound ` - -Action Allow ` - -Program $exePath ` - -Profile Domain, Private, Public ` - -Description "Разрешить исходящие соединения для ProxiFyre" | Out-Null - - Write-Success "Правила Firewall созданы" -} - -function Configure-And-Start { - param($TargetApps, $ProxyAddr) - - # Конфиг (гарантируем, что appNames - массив) - $appNamesArray = @($TargetApps) - $cfg = @{ - logLevel = "Info" - proxies = @(@{ - appNames = $appNamesArray - socks5ProxyEndpoint = $ProxyAddr - supportedProtocols = @("TCP", "UDP") - }) - excludes = @() - } - $cfg | ConvertTo-Json -Depth 5 | Set-Content $ConfigPath -Encoding UTF8 - - # Служба - Write-Step "Перезапуск службы..." - if (Get-DebugMode) { - & "$InstallPath\ProxiFyre.exe" stop - & "$InstallPath\ProxiFyre.exe" install - & "$InstallPath\ProxiFyre.exe" start - } - else { - & "$InstallPath\ProxiFyre.exe" stop 2>&1 | Out-Null - & "$InstallPath\ProxiFyre.exe" install 2>&1 | Out-Null - & "$InstallPath\ProxiFyre.exe" start 2>&1 | Out-Null - } - - # Мониторинг запуска (10 сек) - Write-Info "Проверка стабильности запуска (10 сек)..." - $lastLogSize = 0 - $logFile = $null - - for ($i = 1; $i -le 10; $i++) { - Start-Sleep -Seconds 1 - - # 1. Ищем файл логов (если еще не нашли) - if (-not $logFile) { - $logFile = Get-ChildItem "$InstallPath\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1 - } - - # 2. Выводим новые строки лога - if ($logFile) { - try { - $stream = [System.IO.File]::Open($logFile.FullName, 'Open', 'Read', 'ReadWrite') - if ($stream.Length -gt $lastLogSize) { - $stream.Seek($lastLogSize, 'Begin') | Out-Null - $reader = New-Object System.IO.StreamReader($stream) - $content = $reader.ReadToEnd() - $newPos = $stream.Position # Сохраняем позицию - $reader.Dispose() # Закрывает поток - $lastLogSize = $newPos - - if (-not [string]::IsNullOrWhiteSpace($content)) { - $content -split "`r`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { - Write-Host " LOG: $_" -ForegroundColor DarkGray - } - } - } - else { - $stream.Dispose() - } - } - catch {} - } - - # 3. Проверяем статус службы - $svc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue - if ($svc.Status -ne 'Running') { - Write-Error "Служба упала при запуске! (Код 1064 или другая ошибка)" - Write-Host " Попробуйте запустить вручную для диагностики." -ForegroundColor Gray - return - } - } - - Write-Success "Готово! Служба стабильна." -} - -function Get-AppPath { - Write-Host "`n📁 Укажите путь до папки с приложением" -ForegroundColor Yellow - Write-Host " (Будут проксированы все .exe из этой папки)" -ForegroundColor Gray - - # Стандартные пути установки Discord-клиентов - $defaultPaths = @{ - "Discord" = "$env:LOCALAPPDATA\Discord" - "Discord PTB" = "$env:LOCALAPPDATA\DiscordPTB" - "Discord Canary" = "$env:LOCALAPPDATA\DiscordCanary" - "Vesktop" = "$env:LOCALAPPDATA\vesktop" - "Lightcord" = "$env:LOCALAPPDATA\Lightcord" - } - - $suggestions = @() - foreach ($app in $defaultPaths.Keys) { - if (Test-Path $defaultPaths[$app]) { - $suggestions += @{ Name = $app; Path = $defaultPaths[$app] } - } - } - - if ($suggestions.Count -gt 0) { - Write-Host "`n Найденные приложения:" -ForegroundColor Cyan - for ($i = 0; $i -lt $suggestions.Count; $i++) { - Write-Host " [$($i+1)] $($suggestions[$i].Name): $($suggestions[$i].Path)" -ForegroundColor Gray - } - Write-Host " [c] Указать свой путь" -ForegroundColor Gray - - $choice = Read-Host "`n Выберите" - if ($choice -match "^\d+$" -and [int]$choice -ge 1 -and [int]$choice -le $suggestions.Count) { - return @($suggestions[[int]$choice - 1].Path) - } - } - - # Ручной ввод пути - while ($true) { - $path = Read-Host " Путь до папки" - - if ([string]::IsNullOrWhiteSpace($path)) { - Write-Warning "Путь не указан" - continue - } - - if (Test-Path $path) { - $exeCount = (Get-ChildItem $path -Filter "*.exe" -Recurse -ErrorAction SilentlyContinue).Count - if ($exeCount -gt 0) { - Write-Success "Найдено $exeCount исполняемых файлов" - return @($path) - } - else { - Write-Warning "В папке не найдено .exe файлов" - } - } - else { - Write-Error "Папка не существует: $path" - } - - $retry = Read-Host " Попробовать другой путь? (y/n)" - if ($retry -ne 'y') { return $null } - } -} - -function Select-Apps { - Write-Host "`n🎮 Какие приложения проксировать?" -ForegroundColor Yellow - $appOpts = [Ordered]@{ - "1" = "Discord (по имени процесса)" - "2" = "Vesktop (по имени процесса)" - "3" = "Discord + Vesktop (по имени процесса)" - "4" = "Указать путь до папки приложения" - } - $appChoice = Show-Menu -Options $appOpts - - # Discord запускается через Update.exe, нужно перехватывать оба процесса - $result = switch ($appChoice) { - "1" { @("Discord", "Update") } - "2" { @("Vesktop") } - "3" { @("Vesktop", "Discord", "Update") } - "4" { Get-AppPath } - default { @("Discord", "Update") } - } - return $result -} - -function Get-ProxyAddress { - # Проверяем локальный sing-box - $singboxStatus = Get-TaskStatus -Name "SingBoxProxy" - $localProxy = "127.0.0.1:1080" - - if ($singboxStatus -eq "Running") { - Write-Info "Обнаружен работающий VPN клиент (Sing-box)." - Write-Host " Рекомендуется использовать локальный прокси: " -NoNewline -ForegroundColor Gray - Write-Host $localProxy -ForegroundColor Green - - $useLocal = Read-Host " Использовать локальный? (y/n) [y]" - if ($useLocal -ne 'n') { - return $localProxy - } - } - else { - Write-Warning "VPN клиент не запущен!" - Write-Host " Вы можете указать адрес удалённого прокси." -ForegroundColor Gray - } - - # Запрашиваем адрес - while ($true) { - $proxyAddr = Read-Host "`n Введите адрес прокси (IP:порт)" - - if ([string]::IsNullOrWhiteSpace($proxyAddr)) { - Write-Warning "Адрес не указан" - continue - } - - if ($proxyAddr -notmatch "^[\d\.]+:\d+$") { - Write-Error "Неверный формат. Ожидается: IP:порт (например 192.168.1.100:1080)" - continue - } - - # Проверяем подключение - if (Test-ProxyConnection -ProxyAddr $proxyAddr) { - return $proxyAddr - } - - $retry = Read-Host " Попробовать другой адрес? (y/n)" - if ($retry -ne 'y') { return $null } - } -} - -# --- MAIN --- - -$isInstalled = Test-Path "$InstallPath\ProxiFyre.exe" -$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue -$currentConfig = Get-CurrentConfig - -if ($isInstalled -and $currentConfig -and -not $Force) { - # Уже установлено — показываем меню управления - Write-Info "ProxiFyre уже установлен." - Write-Host "" - Write-Host " Статус: " -NoNewline -ForegroundColor Gray - if ($discSvc.Status -eq 'Running') { - Write-Host "АКТИВЕН" -ForegroundColor Green - } - else { - Write-Host "ОСТАНОВЛЕН" -ForegroundColor Yellow - } - Write-Host " Приложения: $($currentConfig.Apps)" -ForegroundColor Gray - Write-Host " Прокси: $($currentConfig.Proxy)" -ForegroundColor Gray - Write-Host "" - - $opts = [Ordered]@{ - "1" = "Изменить настройки (приложения/прокси)" - "2" = "Проверить подключение к прокси" - "3" = "Перезапустить службу" - "4" = "Остановить службу" - "5" = "Переустановить" - "b" = "Назад" - } - - $action = Show-Menu -Options $opts - - switch ($action) { - "1" { - $targetApps = Select-Apps - $proxyAddr = Get-ProxyAddress - if ($proxyAddr) { - Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr - } - } - "2" { - Test-ProxyConnection -ProxyAddr $currentConfig.Proxy | Out-Null - } - "3" { - Write-Step "Перезапуск службы..." - Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow - Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "start" -Wait -NoNewWindow - Write-Success "Перезапущено!" - } - "4" { - Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow - Write-Success "Остановлено!" - } - "5" { - $Force = $true - } - "b" { exit } - } - - if (-not $Force) { - Start-Sleep -Seconds 2 - exit - } -} - -# --- НОВАЯ УСТАНОВКА --- - -if (-not $isInstalled -or $Force) { - Ensure-VCRedist - Install-ProxiFyre -} - -$targetApps = Select-Apps -$proxyAddr = Get-ProxyAddress - -if (-not $proxyAddr) { - Write-Error "Прокси не настроен. Выход." - Start-Sleep -Seconds 2 - exit -} - -Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr -Start-Sleep -Seconds 3 diff --git a/scripts/setup-singbox.ps1 b/scripts/setup-singbox.ps1 deleted file mode 100644 index 286bbf7..0000000 --- a/scripts/setup-singbox.ps1 +++ /dev/null @@ -1,417 +0,0 @@ -# ========================================== -# 📦 SING-BOX NATIVE INSTALLER -# ========================================== - -param( - [switch]$Force, - [switch]$Debug, - [string]$SubscriptionUrl = "" -) - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. "$ScriptDir\lib\Common.ps1" -. "$ScriptDir\lib\Net.ps1" -. "$ScriptDir\lib\System.ps1" - -# --- CONFIG --- -$SingboxVersion = "1.11.4" -$InstallDir = "C:\Tools\sing-box" -$LocalProxyPort = 1080 -$SingboxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingboxVersion/sing-box-$SingboxVersion-windows-amd64.zip" -$TaskName = "SingBoxProxy" - -Ensure-Admin - -# --- LOGIC --- - -function Select-Server { - param($Config) - - $outbounds = $Config.outbounds - $servers = @() - - foreach ($outbound in $outbounds) { - if ($outbound.type -in @("vless", "vmess", "trojan", "shadowsocks", "hysteria2")) { - $servers += @{ - tag = $outbound.tag - type = $outbound.type - server = $outbound.server - server_port = $outbound.server_port - outbound = $outbound - } - } - } - - if ($servers.Count -eq 0) { - Write-Error "Серверы не найдены в подписке!" - return $null - } - - $options = [Ordered]@{} - for ($i = 0; $i -lt $servers.Count; $i++) { - $s = $servers[$i] - $options["$($i+1)"] = "$($s.tag) ($($s.server):$($s.server_port))" - } - - $choice = Show-Menu -Title "🌐 Доступные серверы" -Options $options -Prompt "👉 Выберите сервер (номер)" - $index = [int]$choice - 1 - - if ($index -lt 0 -or $index -ge $servers.Count) { - Write-Error "Неверный выбор!" - return $null - } - - return $servers[$index] -} - -function New-SingboxConfig { - param($Outbound, $Port) - - return @{ - log = @{ level = "info"; timestamp = $true; output = "$InstallDir\singbox.log" } - dns = @{ independent_cache = $true } - inbounds = @( - @{ - type = "socks" - tag = "socks-in" - listen = "0.0.0.0" - listen_port = $Port - } - ) - outbounds = @( - $Outbound, - @{ type = "direct"; tag = "direct" } - ) - route = @{ - final = $Outbound.tag - auto_detect_interface = $true - } - } -} - -function Parse-VlessUrl { - param([string]$Url) - - if (-not $Url.StartsWith("vless://")) { throw "URL должен начинаться с vless://" } - - # Remove scheme - $raw = $Url.Substring(8) - - # Split fragment - $tag = "reality" - if ($raw -match "#(.*)$") { - $tag = [System.Web.HttpUtility]::UrlDecode($matches[1]) - $raw = $raw -replace "#.*$", "" - } - - # Split query - $queryStr = "" - if ($raw -match "\?(.*)$") { - $queryStr = $matches[1] - $raw = $raw -replace "\?.*$", "" - } - - # Parse UUID@HOST:PORT - if ($raw -notmatch "([^@]+)@([^:]+):(\d+)") { throw "Неверный формат vless (ожидается uuid@host:port)" } - $uuid = $matches[1][0] - $serverHost = $matches[2][0] - $port = [int]$matches[3][0] # Fix for regex object access in PS - - if (-not $uuid) { - # Fallback if regex returns match info differently in different PS versions - $uuid = $matches[1] - $serverHost = $matches[2] - $port = [int]$matches[3] - } - - - # Parse Query - $params = @{} - if ($queryStr) { - $parts = $queryStr -split "&" - foreach ($p in $parts) { - $kv = $p -split "=" - if ($kv.Count -eq 2) { - $params[[System.Web.HttpUtility]::UrlDecode($kv[0])] = [System.Web.HttpUtility]::UrlDecode($kv[1]) - } - } - } - - # Extract - $pbk = if ($params["pbk"]) { $params["pbk"] } else { throw "Отсутствует параметр pbk (Public Key)" } - $sid = if ($params["sid"]) { $params["sid"] } else { throw "Отсутствует параметр sid (Short ID)" } - $sni = if ($params["sni"]) { $params["sni"] } else { $serverHost } - $fp = if ($params["fp"]) { $params["fp"] } else { "chrome" } - $flow = if ($params["flow"]) { $params["flow"] } else { "" } - - return @{ - uuid = $uuid - server = $serverHost - server_port = $port - tag = $tag - public_key = $pbk - short_id = $sid - server_name = $sni - fingerprint = $fp - flow = $flow - } -} - -# --- MAIN --- - -if ($Debug) { Set-DebugMode -Enabled $true } - -Write-Header "NATIVE SING-BOX (UDP ПОДДЕРЖКА)" -ClearScreen - -$taskStatus = Get-TaskStatus -Name $TaskName - -if ($taskStatus -and -not $Force) { - Write-Info "Sing-box уже установлен." - Write-Host " Статус: $taskStatus" -ForegroundColor ($taskStatus -eq "Running" ? "Green" : "Red") - Write-Host "" - - $opts = [Ordered]@{ - "1" = "Сменить сервер (из подписки)" - "2" = "Ввести новую ссылку на подписку" - "3" = "Перезапустить службу" - "4" = "Остановить службу" - "5" = "Показать конфиг" - "6" = "Переустановить" - "b" = "Назад" - } - - $act = Show-Menu -Options $opts - - switch ($act) { - "1" { - # Reload existing sub logic could be added here, currently just re-runs install flow partially - # Simplification: treat as new setup but try to load saved sub url - $Force = $true - } - "2" { $SubscriptionUrl = ""; $Force = $true } - "3" { Manage-ScheduledTask -Name $TaskName -Action "Start"; Write-Success "Запущено!"; exit } - "4" { Manage-ScheduledTask -Name $TaskName -Action "Stop"; Write-Success "Остановлено!"; exit } - "5" { Get-Content "$InstallDir\config.json"; exit } - "6" { $Force = $true } - "b" { exit } - } -} - -if ($Force -or -not $taskStatus) { - # 1. Загрузка - Write-Step "Установка Sing-box..." - if (!(Test-Path "$InstallDir\sing-box.exe")) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - $zipCtx = "$env:TEMP\sing-box.zip" - if (Download-File -Url $SingboxUrl -Destination $zipCtx) { - Expand-Archive -Path $zipCtx -DestinationPath $env:TEMP -Force - $extracted = Get-ChildItem "$env:TEMP\sing-box-*" -Directory | Select -First 1 - Copy-Item "$($extracted.FullName)\sing-box.exe" "$InstallDir\sing-box.exe" -Force - Remove-Item $zipCtx; Remove-Item $extracted.FullName -Recurse -Force - Write-Success "Sing-box скачан" - } - else { - Read-Host "Нажмите Enter для выхода..." - exit 1 - } - } - - # 2. Подписка - if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) { - # Try load saved - $savedSub = "$InstallDir\sub_info.json" - if (Test-Path $savedSub) { - try { - $json = Get-Content $savedSub -Raw | ConvertFrom-Json - if ($json.url) { - Write-Info "Найдена сохраненная подписка: $($json.url)" - if ((Read-Host "Использовать? (y/n)") -eq 'y') { $SubscriptionUrl = $json.url } - } - } - catch {} - } - } - - if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) { - $SubscriptionUrl = Read-Host "`n🔗 Введите URL подписки (VLESS)" - } - - if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) { - Write-Error "Url не указан" - Read-Host "Нажмите Enter для выхода..." - exit - } - - # --- PARSING --- - $data = @{ success = $false; config = $null; error = "" } - - if ($SubscriptionUrl.StartsWith("vless://")) { - try { - $p = Parse-VlessUrl -Url $SubscriptionUrl - $outbound = [Ordered]@{ - type = "vless" - tag = $p.tag - server = $p.server - server_port = $p.server_port - uuid = $p.uuid - flow = $p.flow - tls = @{ - enabled = $true - server_name = $p.server_name - utls = @{ enabled = $true; fingerprint = $p.fingerprint } - reality = @{ - enabled = $true - public_key = $p.public_key - short_id = $p.short_id - } - } - packet_encoding = "xudp" - } - $data.success = $true - $data.config = @{ outbounds = @($outbound) } - } - catch { - $data.error = $_.Exception.Message - } - } - else { - $data = Get-SubscriptionData -Url $SubscriptionUrl -Headers (Get-SubscriptionHeaders) - } - - - # --- PARSING LOGIC ENHANCEMENT --- - if (-not $data.success) { - # Fallback: Try to handle non-JSON body (Base64 or Plain Text) - try { - Write-Info "JSON парсинг не удался, пробую как список ссылок..." - $content = $data.rawContent - - # Base64 decode if needed - if ($content -match "^[A-Za-z0-9+/=]+$") { - try { - $bytes = [System.Convert]::FromBase64String($content) - $content = [System.Text.Encoding]::UTF8.GetString($bytes) - } - catch {} - } - - # Try to find vless:// links - $links = $content -split "[\r\n]+" | Where-Object { $_ -match "^vless://" } - - if ($links.Count -gt 0) { - Write-Success "Найдено ссылок: $($links.Count)" - - # Mock a config object with these links as "outbounds" - # Note: We can't fully parsing VLESS query params in pure PS easily without a lot of regex - # So we will try a simpler approach: Let sing-box do it? No, sing-box needs config. - - # WORKAROUND: Create a minimal outbound for each link - # Parsing `vless://UUID@HOST:PORT?security=reality&...#NAME` - $parsedOutbounds = @() - - foreach ($link in $links) { - if ($link -match "vless://([^@]+)@([^:]+):(\d+)(\?.*)?(#.*)?") { - $uuid = $matches[1] - $server = $matches[2] - $port = [int]$matches[3] - $query = $matches[4] - $hash = $matches[5] - - $tag = if ($hash) { $hash.Substring(1) } else { "${server}:${port}" } - $tag = [System.Web.HttpUtility]::UrlDecode($tag) - - # Parse Query Params - $flow = ""; $fp = ""; $pbk = ""; $sid = ""; $sni = ""; $serviceName = "" - - if ($query) { - if ($query -match "flow=([^&]+)") { $flow = $matches[1] } - if ($query -match "fp=([^&]+)") { $fp = $matches[1] } - if ($query -match "pbk=([^&]+)") { $pbk = $matches[1] } - if ($query -match "sid=([^&]+)") { $sid = $matches[1] } - if ($query -match "sni=([^&]+)") { $sni = $matches[1] } - if ($query -match "serviceName=([^&]+)") { $serviceName = $matches[1] } - } - - # Construct Sing-box outbound (REALITY based assumption for modern vless) - $out = [Ordered]@{ - type = "vless" - tag = $tag - server = $server - server_port = $port - uuid = $uuid - flow = $flow - tls = @{ - enabled = $true - server_name = $sni - utls = @{ enabled = $true; fingerprint = $fp } - reality = @{ - enabled = $true - public_key = $pbk - short_id = $sid - } - } - packet_encoding = "xudp" - } - $parsedOutbounds += $out - } - } - - if ($parsedOutbounds.Count -gt 0) { - $data.success = $true - $data.config = @{ outbounds = $parsedOutbounds } - $data.error = $null - } - else { - throw "Не удалось распарсить VLESS ссылки" - } - - } - else { - throw $data.error - } - } - catch { - Write-Error "Ошибка обработки подписки: $_" - Write-Host " Скрипт поддерживает: SIP008 (JSON) или список VLESS+Reality ссылок." -ForegroundColor Yellow - Read-Host "Нажмите Enter для выхода..." - exit - } - } - - - # Save sub info - @{ url = $SubscriptionUrl } | ConvertTo-Json | Set-Content "$InstallDir\sub_info.json" - - # 3. Выбор сервера - $server = Select-Server -Config $data.config - if (!$server) { - Read-Host "Нажмите Enter для выхода..." - exit - } - - # 4. Конфиг - $cfg = New-SingboxConfig -Outbound $server.outbound -Port $LocalProxyPort - $cfg | ConvertTo-Json -Depth 10 | Set-Content "$InstallDir\config.json" -Encoding UTF8 - - # 5. Задача - Manage-ScheduledTask -Name $TaskName -ExePath "$InstallDir\sing-box.exe" -Arguments "run -c `"$InstallDir\config.json`"" -WorkDir $InstallDir -Action "Install" - Manage-ScheduledTask -Name $TaskName -Action "Start" - - # 6. Firewall - if (Ensure-FirewallPort -Port $LocalProxyPort -Name "SingBox-Proxy-Port") { - Write-Success "Правило Firewall создано (порт $LocalProxyPort)" - } - - Write-Success "Успешно установлено и запущено!" - Write-Info "Локальный прокси: 127.0.0.1:$LocalProxyPort" - - $ips = Get-LocalIPs - if ($ips) { - Write-Info "Доступно из сети по адресам:" - foreach ($ip in $ips) { - Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Gray - } - } - - Start-Sleep -Seconds 3 -} diff --git a/scripts/uninstall-all.ps1 b/scripts/uninstall-all.ps1 deleted file mode 100644 index 72494b3..0000000 --- a/scripts/uninstall-all.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -# ========================================== -# 🗑️ UNINSTALL ALL (CLEANUP) -# ========================================== - -param([switch]$Debug) - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. "$ScriptDir\lib\Common.ps1" -. "$ScriptDir\lib\System.ps1" - -if ($Debug) { Set-DebugMode -Enabled $true } - -Write-Header "ПОЛНОЕ УДАЛЕНИЕ" -ClearScreen - -Ensure-Admin - -Write-Warning "Это действие удалит весь установленный софт:" -Write-Host " - Sing-box (Служба и файлы)" -ForegroundColor Gray -Write-Host " - ProxiFyre (Служба и файлы)" -ForegroundColor Gray -Write-Host " - Драйвер WinPacketFilter" -ForegroundColor Gray -Write-Host "" - -if ((Read-Host "Вы уверены? (y/n)") -ne 'y') { exit } - -Write-Step "Удаление Sing-box..." -Manage-ScheduledTask -Name "SingBoxProxy" -Action "Stop" -Manage-ScheduledTask -Name "SingBoxProxy" -Action "Uninstall" - -if (Test-Path "C:\Tools\sing-box") { - Remove-Item "C:\Tools\sing-box" -Recurse -Force -ErrorAction SilentlyContinue - Write-Success "Файлы удалены" -} - -Write-Step "Удаление Discord Proxy (ProxiFyre)..." -$pfDir = "C:\Tools\ProxiFyre" -if (Test-Path "$pfDir\ProxiFyre.exe") { - if (Get-DebugMode) { - & "$pfDir\ProxiFyre.exe" uninstall - } - else { - & "$pfDir\ProxiFyre.exe" uninstall 2>&1 | Out-Null - } - Start-Sleep -Seconds 2 - Write-Success "Служба удалена" -} - -if (Test-Path $pfDir) { - Remove-Item $pfDir -Recurse -Force -ErrorAction SilentlyContinue - Write-Success "Файлы удалены" -} - -Write-Step "Удаление драйвера..." -# Тут сложно удалить MSI тихо без GUID, но попробуем через known path или пропустим, т.к. драйвер может быть нужен другим -Write-Info "Драйвер WinPacketFilter оставлен (он может использоваться другим ПО)." -Write-Info "Если нужно, удалите его через 'Установка и удаление программ'." - -Write-Success "Очистка завершена!" -Start-Sleep -Seconds 3 diff --git a/scripts/view-logs.ps1 b/scripts/view-logs.ps1 deleted file mode 100644 index bbde619..0000000 --- a/scripts/view-logs.ps1 +++ /dev/null @@ -1,121 +0,0 @@ -# ========================================== -# 📜 LOG VIEWER -# ========================================== -# View logs from sing-box and ProxiFyre - -param([switch]$Follow) - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. "$ScriptDir\lib\Common.ps1" - -$SingboxLog = "C:\Tools\sing-box\singbox.log" -$ProxiFyreLog = "C:\Tools\ProxiFyre" - -function Show-LogFile { - param( - [string]$Path, - [string]$Title, - [int]$Lines = 30, - [string]$Color = "Gray" - ) - - if (Test-Path $Path) { - Write-Host "`n═══ $Title ═══" -ForegroundColor Cyan - $content = Get-Content $Path -Tail $Lines -ErrorAction SilentlyContinue - if ($content) { - $content | ForEach-Object { Write-Host " $_" -ForegroundColor $Color } - } - else { - Write-Host " (Лог пустой)" -ForegroundColor DarkGray - } - } - else { - Write-Host "`n═══ $Title ═══" -ForegroundColor Cyan - Write-Host " (Файл не найден: $Path)" -ForegroundColor DarkGray - } -} - -function Tail-Logs { - Write-Host "`n📜 Режим отслеживания логов (Ctrl+C для выхода)" -ForegroundColor Yellow - Write-Host " sing-box: $SingboxLog" -ForegroundColor DarkGray - Write-Host " ProxiFyre: $ProxiFyreLog\*.log" -ForegroundColor DarkGray - Write-Host "" - - $sbPos = 0 - $pfPos = 0 - $pfLogFile = $null - - # Initial positions - if (Test-Path $SingboxLog) { $sbPos = (Get-Item $SingboxLog).Length } - $pfLogFile = Get-ChildItem "$ProxiFyreLog\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1 - if ($pfLogFile) { $pfPos = $pfLogFile.Length } - - try { - while ($true) { - Start-Sleep -Milliseconds 500 - - # Sing-box - if (Test-Path $SingboxLog) { - $newSize = (Get-Item $SingboxLog).Length - if ($newSize -gt $sbPos) { - $content = Get-Content $SingboxLog -Tail 20 -ErrorAction SilentlyContinue - # Show only new lines (approximate) - $content | Select-Object -Last ([math]::Max(1, [math]::Ceiling(($newSize - $sbPos) / 100))) | ForEach-Object { - Write-Host "[SB] $_" -ForegroundColor Green - } - $sbPos = $newSize - } - } - - # ProxiFyre - $pfLogFile = Get-ChildItem "$ProxiFyreLog\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1 - if ($pfLogFile) { - $newSize = $pfLogFile.Length - if ($newSize -gt $pfPos) { - $content = Get-Content $pfLogFile.FullName -Tail 20 -ErrorAction SilentlyContinue - $content | Select-Object -Last ([math]::Max(1, [math]::Ceiling(($newSize - $pfPos) / 100))) | ForEach-Object { - Write-Host "[PF] $_" -ForegroundColor Yellow - } - $pfPos = $newSize - } - } - } - } - catch { - Write-Host "`nОстановлено." -ForegroundColor Gray - } -} - -# --- MAIN --- - -Write-Header "ПРОСМОТР ЛОГОВ" -ClearScreen - -$opts = [Ordered]@{ - "1" = "Показать последние логи" - "2" = "Следить за логами в реальном времени (tail -f)" - "b" = "Назад" -} - -$choice = Show-Menu -Options $opts - -switch ($choice) { - "1" { - Show-LogFile -Path $SingboxLog -Title "SING-BOX (VPN)" -Lines 50 -Color "Green" - - $pfLog = Get-ChildItem "$ProxiFyreLog\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1 - if ($pfLog) { - Show-LogFile -Path $pfLog.FullName -Title "PROXIFYRE (Discord)" -Lines 50 -Color "Yellow" - } - else { - Write-Host "`n═══ PROXIFYRE (Discord) ═══" -ForegroundColor Cyan - Write-Host " (Логов не найдено в $ProxiFyreLog)" -ForegroundColor DarkGray - } - - Write-Host "" - Read-Host "Нажмите Enter для выхода" - } - "2" { - Tail-Logs - } - "b" { exit } -} diff --git a/src/server/config.js b/src/server/config.js new file mode 100644 index 0000000..4288876 --- /dev/null +++ b/src/server/config.js @@ -0,0 +1,21 @@ +import path from 'node:path'; + +const dataDir = process.env.DATA_DIR || path.resolve('.vpn-proxy'); + +export const settings = { + port: Number(process.env.PORT || 3456), + proxyPort: Number(process.env.PROXY_PORT || 8080), + tproxyPort: Number(process.env.TPROXY_PORT || 7895), + bindIp: process.env.PROXY_BIND_IP || '0.0.0.0', + dataDir, + distDir: process.env.DIST_DIR || '/app/dist', + configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json', + cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db', + statePath: path.join(dataDir, 'state.json'), + customRulesPath: path.join(dataDir, 'custom-rules.json'), + subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'), + hwidPath: path.join(dataDir, 'hwid'), + routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false', + logLevel: process.env.LOG_LEVEL || 'info', + appName: 'VPN Proxy Gateway', +}; diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..daa8c1a --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,274 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { settings } from './config.js'; +import { fetchSubscription } from './subscription.js'; +import { buildGatewayConfig, writeSingboxConfig } from './singbox.js'; + +fs.mkdirSync(settings.dataDir, { recursive: true }); + +let singboxProcess = null; +let singboxStartedAt = null; + +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 sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + if (!chunks.length) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +function checkSingboxConfig() { + const result = spawnSync('sing-box', ['check', '-c', settings.configPath], { + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim()); + } +} + +function stopSingbox() { + return new Promise((resolve) => { + if (!singboxProcess) return resolve(); + + const current = singboxProcess; + singboxProcess = null; + + const timeout = setTimeout(() => { + current.kill('SIGKILL'); + resolve(); + }, 4000); + + current.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + current.kill('SIGTERM'); + }); +} + +async function startSingbox() { + if (!fs.existsSync(settings.configPath)) return false; + + checkSingboxConfig(); + await stopSingbox(); + + singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], { + stdio: 'inherit', + }); + singboxStartedAt = new Date().toISOString(); + + singboxProcess.once('exit', (code, signal) => { + console.log(`[control] sing-box exited: code=${code} signal=${signal}`); + if (singboxProcess?.exitCode === code) singboxProcess = null; + }); + + return true; +} + +function publicState() { + const state = readJson(settings.statePath, {}); + const customRules = readJson(settings.customRulesPath, []); + return { + mode: 'gateway', + port: settings.port, + proxyPort: settings.proxyPort, + tproxyPort: settings.tproxyPort, + routingRuDirect: settings.routingRuDirect, + configExists: fs.existsSync(settings.configPath), + singboxRunning: Boolean(singboxProcess), + singboxStartedAt, + customRules, + ...state, + }; +} + +function normalizeList(value) { + if (Array.isArray(value)) { + return value.map((item) => String(item || '').trim()).filter(Boolean); + } + return String(value || '') + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeCustomRules(input) { + const rules = Array.isArray(input) ? input : []; + return rules.map((rule, index) => ({ + id: String(rule.id || `rule-${Date.now()}-${index}`), + name: String(rule.name || `Rule ${index + 1}`).trim(), + enabled: rule.enabled !== false, + outbound: ['direct', 'vpn', 'block'].includes(rule.outbound) ? rule.outbound : 'direct', + domains: normalizeList(rule.domains), + domainSuffixes: normalizeList(rule.domainSuffixes), + domainKeywords: normalizeList(rule.domainKeywords), + ipCidrs: normalizeList(rule.ipCidrs), + ports: normalizeList(rule.ports), + networks: normalizeList(rule.networks).filter((network) => ['tcp', 'udp'].includes(network)), + })); +} + +async function handleApi(req, res) { + if (req.method === 'GET' && req.url === '/api/state') { + return sendJson(res, 200, publicState()); + } + + if (req.method === 'GET' && req.url === '/api/rules') { + return sendJson(res, 200, { + success: true, + rules: readJson(settings.customRulesPath, []), + }); + } + + if (req.method === 'PUT' && req.url === '/api/rules') { + const body = await readBody(req); + const rules = normalizeCustomRules(body.rules); + writeJson(settings.customRulesPath, rules); + return sendJson(res, 200, { success: true, rules }); + } + + if (req.method === 'POST' && req.url === '/api/subscription/fetch') { + const body = await readBody(req); + const url = String(body.url || '').trim(); + if (!url) return sendJson(res, 400, { success: false, error: 'Subscription URL is required' }); + + const parsed = await fetchSubscription(url); + writeJson(settings.subscriptionCachePath, { url, ...parsed }); + + const prevState = readJson(settings.statePath, {}); + writeJson(settings.statePath, { + ...prevState, + subscriptionUrl: url, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + }); + + return sendJson(res, 200, { success: true, ...parsed }); + } + + if (req.method === 'POST' && req.url === '/api/apply') { + const body = await readBody(req); + const selectedTag = String(body.selectedTag || '').trim(); + if (!selectedTag) return sendJson(res, 400, { success: false, error: 'selectedTag is required' }); + + const cached = readJson(settings.subscriptionCachePath, null); + if (!cached?.config) { + return sendJson(res, 400, { success: false, error: 'Fetch subscription before applying a server' }); + } + + const customRules = readJson(settings.customRulesPath, []); + const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag); + writeSingboxConfig(generated); + await startSingbox(); + + const prevState = readJson(settings.statePath, {}); + writeJson(settings.statePath, { + ...prevState, + selectedTag, + appliedAt: new Date().toISOString(), + }); + + return sendJson(res, 200, { + success: true, + selectedTag, + configPath: settings.configPath, + singboxRunning: Boolean(singboxProcess), + }); + } + + return sendJson(res, 404, { success: false, error: 'Not found' }); +} + +const mime = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.json': 'application/json; charset=utf-8', +}; + +function serveStatic(req, res) { + const requestPath = new URL(req.url, `http://localhost:${settings.port}`).pathname; + const cleanPath = requestPath === '/' ? '/index.html' : requestPath; + const filePath = path.resolve(settings.distDir, `.${cleanPath}`); + const distRoot = path.resolve(settings.distDir); + + if (!filePath.startsWith(distRoot)) { + res.writeHead(403); + return res.end('Forbidden'); + } + + const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() + ? filePath + : path.join(settings.distDir, 'index.html'); + + const ext = path.extname(finalPath); + res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream' }); + fs.createReadStream(finalPath).pipe(res); +} + +const server = http.createServer(async (req, res) => { + try { + if (req.url?.startsWith('/api/')) { + return await handleApi(req, res); + } + return serveStatic(req, res); + } catch (error) { + console.error('[control] request failed', error); + return sendJson(res, 500, { success: false, error: error.message || String(error) }); + } +}); + +process.on('SIGTERM', async () => { + await stopSingbox(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + await stopSingbox(); + process.exit(0); +}); + +await startSingbox().catch((error) => { + console.warn(`[control] sing-box was not started: ${error.message}`); +}); + +server.listen(settings.port, '0.0.0.0', () => { + console.log(`[control] gateway UI listening on :${settings.port}`); +}); diff --git a/src/server/singbox.js b/src/server/singbox.js new file mode 100644 index 0000000..37d279c --- /dev/null +++ b/src/server/singbox.js @@ -0,0 +1,175 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { settings } from './config.js'; + +const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); +const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function findOutbound(subscriptionConfig, selectedTag) { + const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : []; + return outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type)); +} + +function ruleSets() { + if (!settings.routingRuDirect) return []; + + return [ + { + type: 'remote', + tag: 'geoip-ru', + format: 'binary', + url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs', + download_detour: 'direct', + }, + { + type: 'remote', + tag: 'geosite-category-ru', + format: 'binary', + url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs', + download_detour: 'direct', + }, + ]; +} + +function uniqueClean(values) { + return Array.from( + new Set( + (Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean), + ), + ); +} + +function parsePorts(values) { + return uniqueClean(values) + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value) && value > 0 && value <= 65535); +} + +function toSingboxRule(customRule, vpnTag) { + if (!customRule?.enabled) return null; + if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null; + + const rule = {}; + const domains = uniqueClean(customRule.domains); + const domainSuffixes = uniqueClean(customRule.domainSuffixes); + const domainKeywords = uniqueClean(customRule.domainKeywords); + const ipCidrs = uniqueClean(customRule.ipCidrs); + const ports = parsePorts(customRule.ports); + const networks = uniqueClean(customRule.networks).filter((network) => ['tcp', 'udp'].includes(network)); + + if (domains.length) rule.domain = domains; + if (domainSuffixes.length) rule.domain_suffix = domainSuffixes; + if (domainKeywords.length) rule.domain_keyword = domainKeywords; + if (ipCidrs.length) rule.ip_cidr = ipCidrs; + if (ports.length) rule.port = ports; + if (networks.length) rule.network = networks; + + if ( + !rule.domain && + !rule.domain_suffix && + !rule.domain_keyword && + !rule.ip_cidr && + !rule.port && + !rule.network + ) { + return null; + } + + rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound; + return rule; +} + +function customRouteRules(customRules, vpnTag) { + return (Array.isArray(customRules) ? customRules : []) + .map((rule) => toSingboxRule(rule, vpnTag)) + .filter(Boolean); +} + +function routeRules(customRules, vpnTag) { + const rules = [ + { + ip_is_private: true, + outbound: 'direct', + }, + ]; + + rules.push(...customRouteRules(customRules, vpnTag)); + + if (settings.routingRuDirect) { + rules.push({ + rule_set: ['geoip-ru', 'geosite-category-ru'], + outbound: 'direct', + }); + } + + return rules; +} + +export function buildGatewayConfig(subscriptionConfig, selectedTag) { + const selectedOutbound = findOutbound(subscriptionConfig, selectedTag); + if (!selectedOutbound) { + throw new Error(`Selected outbound not found: ${selectedTag}`); + } + + const vpnOutbound = clone(selectedOutbound); + if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; + if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { + vpnOutbound.packet_encoding = 'xudp'; + } + + return { + log: { + level: settings.logLevel, + timestamp: true, + }, + experimental: { + cache_file: { + enabled: true, + path: settings.cachePath, + }, + }, + dns: { + independent_cache: true, + }, + inbounds: [ + { + type: 'tproxy', + tag: 'tproxy-in', + listen: '::', + listen_port: settings.tproxyPort, + sniff: true, + sniff_override_destination: true, + }, + { + type: 'mixed', + tag: 'mixed-in', + listen: settings.bindIp, + listen_port: settings.proxyPort, + sniff: true, + set_system_proxy: false, + }, + ], + outbounds: [ + vpnOutbound, + { type: 'direct', tag: 'direct' }, + { type: 'block', tag: 'block' }, + ], + route: { + rule_set: ruleSets(), + rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), + final: vpnOutbound.tag, + auto_detect_interface: true, + }, + }; +} + +export function writeSingboxConfig(config) { + fs.mkdirSync(path.dirname(settings.configPath), { recursive: true }); + fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8'); +} diff --git a/src/server/subscription.js b/src/server/subscription.js new file mode 100644 index 0000000..9d27053 --- /dev/null +++ b/src/server/subscription.js @@ -0,0 +1,169 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { settings } from './config.js'; + +const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); + +export function getHwid() { + fs.mkdirSync(settings.dataDir, { recursive: true }); + if (fs.existsSync(settings.hwidPath)) { + return fs.readFileSync(settings.hwidPath, 'utf8').trim(); + } + const hwid = crypto.randomBytes(8).toString('hex'); + fs.writeFileSync(settings.hwidPath, hwid, 'utf8'); + return hwid; +} + +export function subscriptionHeaders() { + return { + 'user-agent': 'singbox', + 'x-hwid': getHwid(), + 'x-device-os': process.platform, + 'x-ver-os': process.version, + 'x-device-model': settings.appName, + }; +} + +export function parseUserInfo(headerValue) { + const result = {}; + if (!headerValue) return result; + + for (const part of String(headerValue).split(';')) { + const [key, value] = part.trim().split('=', 2); + if (!key || value === undefined) continue; + const parsed = Number.parseInt(value, 10); + if (!Number.isNaN(parsed)) result[key] = parsed; + } + + return result; +} + +export function parseVlessUrl(rawUrl) { + if (!rawUrl.startsWith('vless://')) { + throw new Error('VLESS URL must start with vless://'); + } + + const parsed = new URL(rawUrl); + const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out'); + const uuid = decodeURIComponent(parsed.username || ''); + const server = parsed.hostname; + const serverPort = Number.parseInt(parsed.port || '443', 10); + const publicKey = parsed.searchParams.get('pbk') || ''; + const shortId = parsed.searchParams.get('sid') || ''; + const serverName = parsed.searchParams.get('sni') || server; + const fingerprint = parsed.searchParams.get('fp') || 'chrome'; + const flow = parsed.searchParams.get('flow') || ''; + + if (!uuid || !server || !serverPort) { + throw new Error('VLESS URL misses uuid, host or port'); + } + + if (!publicKey || !shortId) { + throw new Error('VLESS REALITY parameters pbk and sid are required'); + } + + return { + type: 'vless', + tag, + server, + server_port: serverPort, + uuid, + flow, + tls: { + enabled: true, + server_name: serverName, + utls: { + enabled: true, + fingerprint, + }, + reality: { + enabled: true, + public_key: publicKey, + short_id: shortId, + }, + }, + packet_encoding: 'xudp', + }; +} + +function maybeDecodeBase64(content) { + const compact = content.trim().replace(/\s+/g, ''); + if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content; + + try { + const decoded = Buffer.from(compact, 'base64').toString('utf8'); + if (decoded.includes('vless://') || decoded.includes('{')) return decoded; + } catch {} + + return content; +} + +export function parseSubscriptionBody(body) { + let parsedConfig = null; + + try { + parsedConfig = JSON.parse(body); + } catch { + const decoded = maybeDecodeBase64(body); + const links = decoded + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('vless://')); + + if (!links.length) { + throw new Error('Subscription does not contain JSON config or VLESS links'); + } + + parsedConfig = { + outbounds: links.map(parseVlessUrl), + }; + } + + const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : []; + const servers = outbounds + .filter((outbound) => PROXY_TYPES.has(outbound.type)) + .map((outbound) => ({ + tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`, + type: outbound.type, + server: outbound.server || 'unknown', + server_port: outbound.server_port || 443, + })); + + if (!servers.length) { + throw new Error('No supported proxy outbounds found in subscription'); + } + + return { config: parsedConfig, servers }; +} + +export async function fetchSubscription(url) { + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + throw new Error('Invalid subscription URL'); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error('Subscription URL must use http or https'); + } + + const response = await fetch(parsedUrl, { + headers: subscriptionHeaders(), + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Subscription request failed: HTTP ${response.status}`); + } + + const body = await response.text(); + const userInfo = parseUserInfo(response.headers.get('subscription-userinfo')); + const parsed = parseSubscriptionBody(body); + + return { + ...parsed, + userInfo, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/src/web/App.jsx b/src/web/App.jsx new file mode 100644 index 0000000..5cb88f4 --- /dev/null +++ b/src/web/App.jsx @@ -0,0 +1,451 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles.css'; + +function formatBytes(value) { + 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) { + if (!value) return ''; + try { + const url = new URL(value); + return `${url.hostname}/...`; + } catch { + return value.length > 48 ? `${value.slice(0, 48)}...` : value; + } +} + +function App() { + const [state, setState] = useState(null); + const [subscriptionUrl, setSubscriptionUrl] = useState(''); + const [servers, setServers] = useState([]); + const [customRules, setCustomRules] = useState([]); + const [selectedTag, setSelectedTag] = useState(''); + const [busy, setBusy] = useState(false); + const [log, setLog] = useState([]); + const [error, setError] = useState(''); + const [rulesSaveStatus, setRulesSaveStatus] = useState('saved'); + const rulesDirtyRef = useRef(false); + const rulesSaveTimerRef = useRef(null); + const rulesRevisionRef = useRef(0); + + const userTraffic = useMemo(() => { + const info = state?.userInfo; + if (!info) return 'нет данных'; + const used = formatBytes((info.upload || 0) + (info.download || 0)); + const total = info.total ? formatBytes(info.total) : 'без лимита'; + return `${used} / ${total}`; + }, [state]); + + function addLog(message) { + const time = new Date().toLocaleTimeString('ru-RU', { hour12: false }); + setLog((items) => [{ time, message }, ...items].slice(0, 8)); + } + + async function loadState() { + const response = await fetch('/api/state'); + const data = await response.json(); + setState(data); + setServers(data.servers || []); + if (!rulesDirtyRef.current) { + setCustomRules(data.customRules || []); + } + setSelectedTag(data.selectedTag || ''); + if (data.subscriptionUrl && !subscriptionUrl) setSubscriptionUrl(data.subscriptionUrl); + } + + useEffect(() => { + loadState().catch(() => {}); + const timer = setInterval(() => loadState().catch(() => {}), 5000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + return () => { + if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); + }; + }, []); + + async function fetchServers() { + setBusy(true); + setError(''); + addLog(`SYNC ${maskUrl(subscriptionUrl)}`); + + try { + const response = await fetch('/api/subscription/fetch', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: subscriptionUrl }), + }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || 'sync failed'); + + setServers(data.servers || []); + setSelectedTag(data.servers?.[0]?.tag || ''); + addLog(`FOUND ${data.servers.length} servers`); + 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'}`); + await loadState(); + } catch (err) { + setError(err.message); + addLog(`ERROR ${err.message}`); + } finally { + setBusy(false); + } + } + + function emptyRule() { + return { + id: `rule-${Date.now()}`, + name: 'Новый список', + enabled: true, + outbound: 'direct', + domains: [], + domainSuffixes: [], + 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) { + rulesDirtyRef.current = true; + const revision = rulesRevisionRef.current + 1; + rulesRevisionRef.current = revision; + setRulesSaveStatus('pending'); + + if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); + rulesSaveTimerRef.current = setTimeout(() => { + saveRules(nextRules, { silent: true, revision }); + }, 700); + } + + async function saveRules(nextRules = customRules, options = {}) { + const { silent = false, revision = rulesRevisionRef.current + 1 } = options; + if (!silent) setBusy(true); + setError(''); + if (!silent) addLog('SAVE ROUTING RULES'); + setRulesSaveStatus('saving'); + + try { + const response = await fetch('/api/rules', { + 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) { + rulesDirtyRef.current = false; + setCustomRules(data.rules || []); + setRulesSaveStatus('saved'); + addLog(`RULES SAVED ${data.rules.length}`); + await loadState(); + } else { + setRulesSaveStatus('pending'); + } + } catch (err) { + setError(err.message); + setRulesSaveStatus('error'); + addLog(`ERROR ${err.message}`); + } finally { + if (!silent) setBusy(false); + } + } + + function saveRulesNow() { + if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); + rulesDirtyRef.current = true; + const revision = rulesRevisionRef.current + 1; + rulesRevisionRef.current = revision; + saveRules(customRules, { silent: false, revision }); + } + + function addRule() { + setCustomRules((rules) => { + const nextRules = [emptyRule(), ...rules]; + queueRulesSave(nextRules); + return nextRules; + }); + } + + function removeRule(id) { + setCustomRules((rules) => { + const nextRules = rules.filter((rule) => rule.id !== id); + queueRulesSave(nextRules); + return nextRules; + }); + } + + return ( +
+
+
+

VPN Proxy / Gateway Mode

+

Transparent gateway for the whole network

+

+ Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов. +

+
+
+ +
+ {state?.singboxRunning ? 'sing-box running' : 'sing-box standby'} + {state?.selectedTag || 'сервер не выбран'} +
+
+
+ +
+
+
+ 1 +

Subscription

+
+ + + + + +
+ 2 +

Servers

+
+ +
+ {servers.length === 0 &&
Серверы еще не загружены
} + {servers.map((server) => ( + + ))} +
+ + + + {error &&
{error}
} +
+ + +
+ +
+
+
+ 4 +

Routing lists

+
+
+ + +
+
+ +

+ Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит. +

+ +
+ {customRules.length === 0 && ( +
+ Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`. +
+ )} + + {customRules.map((rule) => ( +
+
+ updateRule(rule.id, { name: event.target.value })} + placeholder="Название списка" + /> + +
+ + + +
+