develop #1
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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
|
||||||
@@ -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
|
|
||||||
28
.gitea/workflows/gateway-build.yml
Normal file
28
.gitea/workflows/gateway-build.yml
Normal file
@@ -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 }}"
|
||||||
104
.github/copilot-instructions.md
vendored
104
.github/copilot-instructions.md
vendored
@@ -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 (<!-- include "components/X.html" -->)
|
|
||||||
└─ 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`.
|
|
||||||
66
.gitignore
vendored
66
.gitignore
vendored
@@ -1,60 +1,24 @@
|
|||||||
# Python
|
# Local archive with the previous implementation and runtime secrets
|
||||||
__pycache__/
|
_archive/
|
||||||
*.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
|
|
||||||
|
|
||||||
# Virtual Env
|
# Runtime state
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
env/
|
|
||||||
.env
|
.env
|
||||||
|
*.env.local
|
||||||
|
data/
|
||||||
|
.vpn-proxy/
|
||||||
|
|
||||||
# PyInstaller
|
# Node/Vite
|
||||||
*.manifest
|
node_modules/
|
||||||
*.spec
|
dist/
|
||||||
|
coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
# MacOS
|
# OS/editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
|
||||||
.LSOverride
|
|
||||||
._*
|
|
||||||
.DocumentRevisions-V100
|
|
||||||
.fseventsd
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
.VolumeIcon.icns
|
|
||||||
.com.apple.timemachine.donotpresent
|
|
||||||
|
|
||||||
# IDE & Editors
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
|
||||||
|
|
||||||
# Project Specific
|
|
||||||
data/
|
|
||||||
_legacy/
|
|
||||||
*.log
|
|
||||||
sing-box
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|||||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -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"]
|
||||||
379
README.md
379
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-прокси** на вашем компьютере
|
```bash
|
||||||
2. **Управлять через удобное меню** — всё настраивается автоматически
|
cp .env.example .env
|
||||||
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
|
|
||||||
|
|
||||||
### 🎯 Для кого это?
|
|
||||||
|
|
||||||
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
|
|
||||||
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
|
|
||||||
- Геймеры, которым нужно запустить игры или Discord через VPN
|
|
||||||
- Люди, которые получили VLESS ссылку от VPN-провайдера
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 Как это работает?
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет
|
|
||||||
│ или Discord │ │ (порт 1080) │ │ (в другой стране)│
|
|
||||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
UI будет доступен на хосте по `http://<gateway-host>:3456`.
|
||||||
|
|
||||||
## 🔧 Перед началом: Требования
|
## Важные ограничения v0.1
|
||||||
|
|
||||||
### ✅ PowerShell 7 (Обязательно!)
|
- IPv4 TProxy first. IPv6 routing будет отдельным этапом.
|
||||||
|
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать клиентам DNS через роутер/DHCP.
|
||||||
> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!**
|
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
|
||||||
|
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state.
|
||||||
#### Проверьте вашу версию
|
- Gateway не видит process name на клиентском ПК, поэтому правила для игр задаются через домены, suffix, IP CIDR и порты.
|
||||||
|
|
||||||
Откройте любой 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]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Создано для простого и безопасного доступа в интернет_ 🛡️
|
|
||||||
|
|||||||
24
docker-compose.gateway.yml
Normal file
24
docker-compose.gateway.yml
Normal file
@@ -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:
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
208
docs/DOCKER.md
208
docs/DOCKER.md
@@ -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)
|
|
||||||
278
docs/SERVER.md
278
docs/SERVER.md
@@ -1,278 +0,0 @@
|
|||||||
# 🌍 Установка на Сервер (Linux VPS)
|
|
||||||
|
|
||||||
> Эта инструкция для установки прокси на удалённый сервер. После установки вы сможете подключаться к нему с любого устройства.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Зачем это нужно?
|
|
||||||
|
|
||||||
- 🌐 **Один прокси для всех устройств** — компьютер, телефон, планшет
|
|
||||||
- 🔒 **Работает 24/7** — не нужно держать компьютер включённым
|
|
||||||
- 📡 **Полная поддержка UDP** — голосовые звонки и игры работают отлично
|
|
||||||
- 🏠 **Доступ из любого места** — дома, на работе, в поездке
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Требования к серверу
|
|
||||||
|
|
||||||
- **ОС:** Ubuntu 20.04+, Debian 11+, или любой современный Linux
|
|
||||||
- **Ресурсы:** Минимум 512 MB RAM, 1 CPU
|
|
||||||
- **Порты:** 3456 (веб-интерфейс), 8080 (прокси)
|
|
||||||
- **Доступ:** SSH подключение
|
|
||||||
|
|
||||||
> 💡 Подойдёт любой VPS за $3-5/месяц от DigitalOcean, Vultr, Hetzner и др.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Установка
|
|
||||||
|
|
||||||
### Шаг 1: Подключитесь к серверу
|
|
||||||
|
|
||||||
Откройте терминал (PowerShell на Windows, Terminal на Mac/Linux):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh root@ваш_сервер_ip
|
|
||||||
```
|
|
||||||
|
|
||||||
Введите пароль когда попросят.
|
|
||||||
|
|
||||||
> 💡 **Совет:** Если вы на Windows и нет ssh команды, используйте PuTTY или Windows Terminal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 2: Установите Docker
|
|
||||||
|
|
||||||
Если Docker ещё не установлен:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Автоматическая установка Docker
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
|
|
||||||
# Проверка что Docker работает
|
|
||||||
docker --version
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 3: Загрузите проект
|
|
||||||
|
|
||||||
**Вариант A: Через Git**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-repo/vpn-proxy.git
|
|
||||||
cd vpn-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
**Вариант B: Загрузка файлов вручную**
|
|
||||||
|
|
||||||
Если git недоступен, скачайте ZIP архив и распакуйте на сервере.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 4: Запустите контейнер
|
|
||||||
|
|
||||||
> ⚠️ **Важно:** Используйте `docker-compose.server.yml` — он настроен для серверов!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.server.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Это запустит контейнер с `network_mode: host`, что решает проблемы с UDP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 5: Откройте порты в файрволе
|
|
||||||
|
|
||||||
**Для UFW (Ubuntu/Debian):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ufw allow 3456/tcp # Веб-интерфейс
|
|
||||||
ufw allow 8080/tcp # Прокси TCP
|
|
||||||
ufw allow 8080/udp # Прокси UDP (для голоса/игр)
|
|
||||||
ufw reload
|
|
||||||
```
|
|
||||||
|
|
||||||
**Для firewalld (CentOS/RHEL):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
firewall-cmd --permanent --add-port=3456/tcp
|
|
||||||
firewall-cmd --permanent --add-port=8080/tcp
|
|
||||||
firewall-cmd --permanent --add-port=8080/udp
|
|
||||||
firewall-cmd --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
**Для iptables:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
iptables -A INPUT -p tcp --dport 3456 -j ACCEPT
|
|
||||||
iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
|
|
||||||
iptables -A INPUT -p udp --dport 8080 -j ACCEPT
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 6: Настройте VPN через веб-интерфейс
|
|
||||||
|
|
||||||
1. Откройте в браузере: `http://ваш_сервер_ip:3456`
|
|
||||||
2. Вставьте VLESS-ссылку или URL подписки
|
|
||||||
3. Нажмите "Применить"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Проверка работы
|
|
||||||
|
|
||||||
На сервере:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить что контейнер запущен
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Посмотреть логи
|
|
||||||
docker logs --tail 20 sing-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
С вашего компьютера:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить прокси
|
|
||||||
curl -x http://ваш_сервер_ip:8080 https://ipinfo.io/ip
|
|
||||||
```
|
|
||||||
|
|
||||||
Должен показать IP VPN-сервера (не IP вашего VPS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🖥️ Подключение с Windows
|
|
||||||
|
|
||||||
### Настройка в manage.ps1
|
|
||||||
|
|
||||||
При настройке Discord (пункт [2]) вы можете указать адрес удалённого прокси:
|
|
||||||
|
|
||||||
```
|
|
||||||
Введите адрес прокси (IP:порт): ваш_сервер_ip:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Настройка в браузере/приложениях
|
|
||||||
|
|
||||||
- **Адрес:** `ваш_сервер_ip`
|
|
||||||
- **Порт:** `8080`
|
|
||||||
- **Тип:** HTTP или SOCKS5
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Управление
|
|
||||||
|
|
||||||
| Действие | Команда |
|
|
||||||
|----------|---------|
|
|
||||||
| Посмотреть статус | `docker ps` |
|
|
||||||
| Логи | `docker logs --tail 50 sing-proxy` |
|
|
||||||
| Остановить | `docker compose -f docker-compose.server.yml stop` |
|
|
||||||
| Запустить | `docker compose -f docker-compose.server.yml start` |
|
|
||||||
| Перезапустить | `docker compose -f docker-compose.server.yml restart` |
|
|
||||||
| Удалить | `docker compose -f docker-compose.server.yml down` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Рекомендации по безопасности
|
|
||||||
|
|
||||||
### 1. Смените стандартные порты
|
|
||||||
|
|
||||||
Отредактируйте `docker-compose.server.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- PORT=54321 # Вместо 3456
|
|
||||||
- PROXY_PORT=12345 # Вместо 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Ограничьте доступ к веб-интерфейсу
|
|
||||||
|
|
||||||
Если веб-интерфейс нужен только для первоначальной настройки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Закрыть веб-порт после настройки
|
|
||||||
ufw delete allow 3456/tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Используйте SSH туннель
|
|
||||||
|
|
||||||
Для безопасного доступа к веб-интерфейсу:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -L 3456:localhost:3456 root@ваш_сервер_ip
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем откройте http://localhost:3456 в браузере.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Обновление
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd vpn-proxy
|
|
||||||
|
|
||||||
# Получить обновления
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# Пересобрать контейнер
|
|
||||||
docker compose -f docker-compose.server.yml down
|
|
||||||
docker compose -f docker-compose.server.yml build --no-cache
|
|
||||||
docker compose -f docker-compose.server.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Проблемы и решения
|
|
||||||
|
|
||||||
### Порт 3456 не открывается
|
|
||||||
|
|
||||||
**Причина:** Файрвол блокирует подключения.
|
|
||||||
|
|
||||||
**Решение:** Проверьте настройки файрвола, см. Шаг 5.
|
|
||||||
|
|
||||||
### "Permission denied" при запуске Docker
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Добавить пользователя в группу docker
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
|
|
||||||
# Перезайти
|
|
||||||
exit
|
|
||||||
ssh root@ваш_сервер_ip
|
|
||||||
```
|
|
||||||
|
|
||||||
### Контейнер постоянно перезапускается
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Посмотреть логи ошибок
|
|
||||||
docker logs sing-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
Обычно проблема в неверной VLESS-ссылке.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📐 Изменение портов
|
|
||||||
|
|
||||||
По умолчанию:
|
|
||||||
- **3456** — веб-интерфейс
|
|
||||||
- **8080** — прокси
|
|
||||||
|
|
||||||
Для изменения создайте файл `.env` в папке проекта:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PORT=54321
|
|
||||||
PROXY_PORT=12345
|
|
||||||
```
|
|
||||||
|
|
||||||
И перезапустите:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.server.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[← Вернуться к основной инструкции](../README.md)
|
|
||||||
105
docs/roadmap.md
Normal file
105
docs/roadmap.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Roadmap: VPN Proxy rebuild
|
||||||
|
|
||||||
|
## Целевая модель
|
||||||
|
|
||||||
|
Проект должен стать multi-mode системой вокруг `sing-box`:
|
||||||
|
|
||||||
|
| Режим | Назначение | Runtime | Статус |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
||||||
|
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
||||||
|
| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
|
||||||
|
|
||||||
|
## Gateway mode
|
||||||
|
|
||||||
|
Цель: контейнер, который становится прозрачным gateway для сети.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- `sing-box` внутри контейнера.
|
||||||
|
- `network_mode: host`.
|
||||||
|
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
|
||||||
|
- TProxy inbound на `7895`.
|
||||||
|
- Mixed HTTP/SOCKS inbound на `8080`.
|
||||||
|
- Web UI на `3456`.
|
||||||
|
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
|
||||||
|
- Пользовательские routing lists управляются из UI.
|
||||||
|
- Генерируется `/etc/sing-box/config.json`.
|
||||||
|
- `sing-box check` перед применением.
|
||||||
|
- Restart `sing-box` после применения.
|
||||||
|
- Idempotent iptables setup.
|
||||||
|
- Cleanup iptables/ip rule/ip route при остановке контейнера.
|
||||||
|
|
||||||
|
Маршрутизация v1:
|
||||||
|
|
||||||
|
- private IP ranges -> `direct`.
|
||||||
|
- пользовательские списки -> `direct`, `vpn` или `block`.
|
||||||
|
- `geoip-ru` -> `direct`.
|
||||||
|
- `geosite-category-ru` -> `direct`.
|
||||||
|
- все остальное -> выбранный VPN outbound.
|
||||||
|
|
||||||
|
Порядок правил:
|
||||||
|
|
||||||
|
1. safety private-direct, чтобы не ломать LAN.
|
||||||
|
2. custom routing lists из UI.
|
||||||
|
3. RU direct rules.
|
||||||
|
4. default VPN outbound.
|
||||||
|
|
||||||
|
Формат пользовательского списка:
|
||||||
|
|
||||||
|
- `name`.
|
||||||
|
- `enabled`.
|
||||||
|
- `outbound`: `direct`, `vpn`, `block`.
|
||||||
|
- `domains`: exact domains.
|
||||||
|
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
|
||||||
|
- `domainKeywords`: keyword matching.
|
||||||
|
- `ipCidrs`: CIDR ranges.
|
||||||
|
- `ports`: TCP/UDP ports.
|
||||||
|
- `networks`: `tcp`, `udp`.
|
||||||
|
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
|
||||||
|
|
||||||
|
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
|
||||||
|
|
||||||
|
Отдельно решить позже:
|
||||||
|
|
||||||
|
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
|
||||||
|
- IPv6 TProxy.
|
||||||
|
- nftables backend.
|
||||||
|
- health checks и smoke diagnostics.
|
||||||
|
- secret storage через Infisical/Vault/env.
|
||||||
|
|
||||||
|
## Desktop proxy mode
|
||||||
|
|
||||||
|
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- UI на `3456`.
|
||||||
|
- Mixed inbound на `8080`.
|
||||||
|
- Subscription parser.
|
||||||
|
- Выбор сервера.
|
||||||
|
- Fallback proxy через `urltest`.
|
||||||
|
- Direct mode toggle.
|
||||||
|
- Не требует `NET_ADMIN`.
|
||||||
|
|
||||||
|
## Windows gaming mode
|
||||||
|
|
||||||
|
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- Native `sing-box.exe`.
|
||||||
|
- Scheduled task или Windows service.
|
||||||
|
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
||||||
|
- Управление из PowerShell helper.
|
||||||
|
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
||||||
|
|
||||||
|
## Рабочий порядок
|
||||||
|
|
||||||
|
1. Сделать новый gateway root.
|
||||||
|
2. Реализовать Docker image + entrypoint TProxy lifecycle.
|
||||||
|
3. Реализовать маленький control-server.
|
||||||
|
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
||||||
|
5. Добавить gateway docs/install script.
|
||||||
|
6. Потом переносить desktop-proxy.
|
||||||
|
7. Потом приводить Windows mode к новой архитектуре.
|
||||||
63
entrypoint.sh
Normal file
63
entrypoint.sh
Normal file
@@ -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"
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VPN Proxy Gateway</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/web/App.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
install.ps1
104
install.ps1
@@ -1,104 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🚀 VPN PROXY INSTALLER
|
|
||||||
# ==========================================
|
|
||||||
# This script automatically downloads and installs VPN Proxy
|
|
||||||
# Usage:
|
|
||||||
# iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
|
|
||||||
|
|
||||||
# Enable UTF-8 for emoji support
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# --- 1. Check Admin Rights ---
|
|
||||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) {
|
|
||||||
Write-Warning "⚠️ Administrator rights required!"
|
|
||||||
Write-Host "🔄 Restarting script as Administrator..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# Save script to temp file if running from memory (iex)
|
|
||||||
if ($MyInvocation.MyCommand.CommandType -eq 'Script') {
|
|
||||||
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# If running via IEX, we cannot simple restart the file.
|
|
||||||
# We ask user to run terminal as admin.
|
|
||||||
Write-Error "Please run PowerShell as Administrator and try again."
|
|
||||||
}
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 2. Settings ---
|
|
||||||
$InstallRoot = "C:\Tools"
|
|
||||||
$InstallDir = "$InstallRoot\vpn-proxy"
|
|
||||||
# Exact link provided by user
|
|
||||||
$ZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip"
|
|
||||||
$TempZip = "$env:TEMP\vpn-proxy-install.zip"
|
|
||||||
|
|
||||||
Write-Host "🚀 Starting VPN Proxy installation..." -ForegroundColor Green
|
|
||||||
Write-Host "📂 Install path: $InstallDir" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# Move to temp folder to avoid blocking deletion if we are already in C:\Tools\vpn-proxy
|
|
||||||
Set-Location $env:TEMP
|
|
||||||
|
|
||||||
# --- 3. Prepare Directory ---
|
|
||||||
if (-not (Test-Path $InstallRoot)) {
|
|
||||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 4. Downloading ---
|
|
||||||
Write-Host "⬇️ Downloading update archive..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
Invoke-WebRequest -Uri $ZipUrl -OutFile $TempZip
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "❌ Failed to download from $ZipUrl`nCheck your internet connection."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 5. Extracting ---
|
|
||||||
Write-Host "📦 Extracting..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# If folder exists, delete old one
|
|
||||||
if (Test-Path $InstallDir) {
|
|
||||||
try {
|
|
||||||
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "⚠️ Failed to delete old folder $InstallDir"
|
|
||||||
Write-Warning " Error: $($_.Exception.Message)"
|
|
||||||
Write-Warning " Make sure files are not open in other programs and you are not inside this folder."
|
|
||||||
|
|
||||||
$retry = Read-Host " Press Enter to try again (or Ctrl+C to cancel)"
|
|
||||||
try {
|
|
||||||
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "❌ Still failed to delete folder. Installation aborted."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Expand-Archive -Path $TempZip -DestinationPath $InstallRoot -Force
|
|
||||||
|
|
||||||
# Archives usually extract to vpn-proxy-master or vpn-proxy-main
|
|
||||||
# We need to rename it to vpn-proxy
|
|
||||||
$ExtractedFolder = Get-ChildItem -Path $InstallRoot -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1
|
|
||||||
|
|
||||||
if ($ExtractedFolder) {
|
|
||||||
Rename-Item -Path $ExtractedFolder.FullName -NewName "vpn-proxy" -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove temp archive
|
|
||||||
Remove-Item $TempZip -Force
|
|
||||||
|
|
||||||
if (-not (Test-Path "$InstallDir\manage.ps1")) {
|
|
||||||
Write-Error "❌ Installation error: manage.ps1 not found in $InstallDir"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 6. Finish ---
|
|
||||||
Write-Host "✅ Installation complete!" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "To start the control menu, run:" -ForegroundColor Cyan
|
|
||||||
Write-Host "& `"$InstallDir\manage.ps1`"" -ForegroundColor Yellow
|
|
||||||
Write-Host ""
|
|
||||||
96
manage.ps1
96
manage.ps1
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🛠️ COMMON UTILS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
# --- ГЛОБАЛЬНЫЕ НАСТРОЙКИ ---
|
|
||||||
|
|
||||||
# Режим отладки (передаётся через -Debug)
|
|
||||||
if (-not (Test-Path variable:script:DebugMode)) {
|
|
||||||
$script:DebugMode = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
function Set-DebugMode {
|
|
||||||
param([bool]$Enabled)
|
|
||||||
$script:DebugMode = $Enabled
|
|
||||||
if ($Enabled) {
|
|
||||||
Write-Host " 🔧 Debug режим включён" -ForegroundColor Magenta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-DebugMode {
|
|
||||||
return $script:DebugMode
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- ЦВЕТА И ВЫВОД ---
|
|
||||||
|
|
||||||
function Write-Step { param($msg) Write-Host "`n📦 $msg" -ForegroundColor Cyan }
|
|
||||||
function Write-Success { param($msg) Write-Host " ✅ $msg" -ForegroundColor Green }
|
|
||||||
function Write-Warning { param($msg) Write-Host " ⚠️ $msg" -ForegroundColor Yellow }
|
|
||||||
function Write-Error { param($msg) Write-Host " ❌ $msg" -ForegroundColor Red }
|
|
||||||
function Write-Info { param($msg) Write-Host " ℹ️ $msg" -ForegroundColor Gray }
|
|
||||||
|
|
||||||
function Write-DebugLog {
|
|
||||||
param($msg)
|
|
||||||
if ($script:DebugMode) {
|
|
||||||
Write-Host " [DEBUG] $msg" -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Write-Header {
|
|
||||||
param($Title, [switch]$ClearScreen)
|
|
||||||
|
|
||||||
if ($ClearScreen -and -not $script:DebugMode) {
|
|
||||||
Clear-Host
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "==========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host " $Title" -ForegroundColor Cyan
|
|
||||||
Write-Host "==========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- ЗАПУСК КОМАНД ---
|
|
||||||
|
|
||||||
function Invoke-Silent {
|
|
||||||
param(
|
|
||||||
[string]$FilePath,
|
|
||||||
[string]$Arguments,
|
|
||||||
[switch]$Wait
|
|
||||||
)
|
|
||||||
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = $FilePath
|
|
||||||
$psi.Arguments = $Arguments
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
|
|
||||||
if (-not $script:DebugMode) {
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
|
|
||||||
if ($Wait) {
|
|
||||||
$process.WaitForExit()
|
|
||||||
return $process.ExitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return $process
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- ПОЛЕЗНЫЕ ФУНКЦИИ ---
|
|
||||||
|
|
||||||
function Get-ScriptDirectory {
|
|
||||||
if ($PSScriptRoot) { return $PSScriptRoot }
|
|
||||||
return Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
function Ensure-Admin {
|
|
||||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
|
|
||||||
if (-not $isAdmin) {
|
|
||||||
Write-Host "⛔ Требуются права АДМИНИСТРАТОРА!" -ForegroundColor Red
|
|
||||||
Write-Host " Пожалуйста, запустите скрипт от имени администратора." -ForegroundColor Gray
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Show-Menu {
|
|
||||||
param(
|
|
||||||
[string]$Title,
|
|
||||||
[System.Collections.Specialized.OrderedDictionary]$Options,
|
|
||||||
[string]$Prompt = "👉 Ваш выбор"
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($Title) {
|
|
||||||
Write-Host "`n$Title" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
$keys = $Options.Keys
|
|
||||||
foreach ($key in $keys) {
|
|
||||||
Write-Host " [$key] $($Options[$key])" -ForegroundColor White
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
return Read-Host "$Prompt"
|
|
||||||
}
|
|
||||||
@@ -1,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
21
src/server/config.js
Normal file
21
src/server/config.js
Normal file
@@ -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',
|
||||||
|
};
|
||||||
274
src/server/index.js
Normal file
274
src/server/index.js
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
175
src/server/singbox.js
Normal file
175
src/server/singbox.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
169
src/server/subscription.js
Normal file
169
src/server/subscription.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
|
||||||
|
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
|
|
||||||
|
export function getHwid() {
|
||||||
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
if (fs.existsSync(settings.hwidPath)) {
|
||||||
|
return fs.readFileSync(settings.hwidPath, 'utf8').trim();
|
||||||
|
}
|
||||||
|
const hwid = crypto.randomBytes(8).toString('hex');
|
||||||
|
fs.writeFileSync(settings.hwidPath, hwid, 'utf8');
|
||||||
|
return hwid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionHeaders() {
|
||||||
|
return {
|
||||||
|
'user-agent': 'singbox',
|
||||||
|
'x-hwid': getHwid(),
|
||||||
|
'x-device-os': process.platform,
|
||||||
|
'x-ver-os': process.version,
|
||||||
|
'x-device-model': settings.appName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserInfo(headerValue) {
|
||||||
|
const result = {};
|
||||||
|
if (!headerValue) return result;
|
||||||
|
|
||||||
|
for (const part of String(headerValue).split(';')) {
|
||||||
|
const [key, value] = part.trim().split('=', 2);
|
||||||
|
if (!key || value === undefined) continue;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isNaN(parsed)) result[key] = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVlessUrl(rawUrl) {
|
||||||
|
if (!rawUrl.startsWith('vless://')) {
|
||||||
|
throw new Error('VLESS URL must start with vless://');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
|
||||||
|
const uuid = decodeURIComponent(parsed.username || '');
|
||||||
|
const server = parsed.hostname;
|
||||||
|
const serverPort = Number.parseInt(parsed.port || '443', 10);
|
||||||
|
const publicKey = parsed.searchParams.get('pbk') || '';
|
||||||
|
const shortId = parsed.searchParams.get('sid') || '';
|
||||||
|
const serverName = parsed.searchParams.get('sni') || server;
|
||||||
|
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
|
||||||
|
const flow = parsed.searchParams.get('flow') || '';
|
||||||
|
|
||||||
|
if (!uuid || !server || !serverPort) {
|
||||||
|
throw new Error('VLESS URL misses uuid, host or port');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicKey || !shortId) {
|
||||||
|
throw new Error('VLESS REALITY parameters pbk and sid are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'vless',
|
||||||
|
tag,
|
||||||
|
server,
|
||||||
|
server_port: serverPort,
|
||||||
|
uuid,
|
||||||
|
flow,
|
||||||
|
tls: {
|
||||||
|
enabled: true,
|
||||||
|
server_name: serverName,
|
||||||
|
utls: {
|
||||||
|
enabled: true,
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
reality: {
|
||||||
|
enabled: true,
|
||||||
|
public_key: publicKey,
|
||||||
|
short_id: shortId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
packet_encoding: 'xudp',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeDecodeBase64(content) {
|
||||||
|
const compact = content.trim().replace(/\s+/g, '');
|
||||||
|
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(compact, 'base64').toString('utf8');
|
||||||
|
if (decoded.includes('vless://') || decoded.includes('{')) return decoded;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionBody(body) {
|
||||||
|
let parsedConfig = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedConfig = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
const decoded = maybeDecodeBase64(body);
|
||||||
|
const links = decoded
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith('vless://'));
|
||||||
|
|
||||||
|
if (!links.length) {
|
||||||
|
throw new Error('Subscription does not contain JSON config or VLESS links');
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedConfig = {
|
||||||
|
outbounds: links.map(parseVlessUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||||
|
const servers = outbounds
|
||||||
|
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||||
|
.map((outbound) => ({
|
||||||
|
tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`,
|
||||||
|
type: outbound.type,
|
||||||
|
server: outbound.server || 'unknown',
|
||||||
|
server_port: outbound.server_port || 443,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!servers.length) {
|
||||||
|
throw new Error('No supported proxy outbounds found in subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: parsedConfig, servers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscription(url) {
|
||||||
|
let parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid subscription URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||||
|
throw new Error('Subscription URL must use http or https');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(parsedUrl, {
|
||||||
|
headers: subscriptionHeaders(),
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||||
|
const parsed = parseSubscriptionBody(body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
userInfo,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
451
src/web/App.jsx
Normal file
451
src/web/App.jsx
Normal file
@@ -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 (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="hero panel">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">VPN Proxy / Gateway Mode</p>
|
||||||
|
<h1>Transparent gateway for the whole network</h1>
|
||||||
|
<p className="lead">
|
||||||
|
Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="status-card">
|
||||||
|
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
|
||||||
|
<div>
|
||||||
|
<strong>{state?.singboxRunning ? 'sing-box running' : 'sing-box standby'}</strong>
|
||||||
|
<small>{state?.selectedTag || 'сервер не выбран'}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid">
|
||||||
|
<div className="panel primary-flow">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>1</span>
|
||||||
|
<h2>Subscription</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Subscription URL</span>
|
||||||
|
<input
|
||||||
|
value={subscriptionUrl}
|
||||||
|
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||||
|
placeholder="https://provider.example/sub/..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="button" disabled={busy || !subscriptionUrl} onClick={fetchServers}>
|
||||||
|
{busy ? 'Working...' : 'Parse subscription'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="section-title compact">
|
||||||
|
<span>2</span>
|
||||||
|
<h2>Servers</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="server-list">
|
||||||
|
{servers.length === 0 && <div className="empty">Серверы еще не загружены</div>}
|
||||||
|
{servers.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.tag}
|
||||||
|
className={server.tag === selectedTag ? 'server active' : 'server'}
|
||||||
|
onClick={() => setSelectedTag(server.tag)}
|
||||||
|
>
|
||||||
|
<strong>{server.tag}</strong>
|
||||||
|
<small>{server.type} / {server.server}:{server.server_port}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="button apply" disabled={busy || !selectedTag} onClick={applyServer}>
|
||||||
|
Apply selected gateway route
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="panel details">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>3</span>
|
||||||
|
<h2>Gateway runtime</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl>
|
||||||
|
<div><dt>UI</dt><dd>:{state?.port || 3456}</dd></div>
|
||||||
|
<div><dt>Mixed proxy</dt><dd>:{state?.proxyPort || 8080}</dd></div>
|
||||||
|
<div><dt>TProxy</dt><dd>:{state?.tproxyPort || 7895}</dd></div>
|
||||||
|
<div><dt>RU direct</dt><dd>{state?.routingRuDirect ? 'enabled' : 'disabled'}</dd></div>
|
||||||
|
<div><dt>Traffic</dt><dd>{userTraffic}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="route-card">
|
||||||
|
<span>Routing policy</span>
|
||||||
|
<p>private IP -> direct</p>
|
||||||
|
<p>geoip-ru/geosite-category-ru -> direct</p>
|
||||||
|
<p>everything else -> selected VPN outbound</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="logs">
|
||||||
|
{log.length === 0 && <p>Waiting for actions...</p>}
|
||||||
|
{log.map((entry) => (
|
||||||
|
<p key={`${entry.time}-${entry.message}`}><span>{entry.time}</span> {entry.message}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel rules-panel">
|
||||||
|
<div className="rules-header">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>4</span>
|
||||||
|
<h2>Routing lists</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rules-actions">
|
||||||
|
<button className="ghost-button" type="button" onClick={addRule}>Add list</button>
|
||||||
|
<button className="ghost-button solid" type="button" disabled={busy || rulesSaveStatus === 'saving'} onClick={saveRulesNow}>
|
||||||
|
{rulesSaveStatus === 'saving' ? 'Saving...' : rulesSaveStatus === 'pending' ? 'Save now' : rulesSaveStatus === 'error' ? 'Retry save' : 'Saved'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rules-note">
|
||||||
|
Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rule-grid">
|
||||||
|
{customRules.length === 0 && (
|
||||||
|
<div className="empty rule-empty">
|
||||||
|
Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customRules.map((rule) => (
|
||||||
|
<article className="rule-card" key={rule.id}>
|
||||||
|
<div className="rule-top">
|
||||||
|
<input
|
||||||
|
value={rule.name}
|
||||||
|
onChange={(event) => updateRule(rule.id, { name: event.target.value })}
|
||||||
|
placeholder="Название списка"
|
||||||
|
/>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.enabled}
|
||||||
|
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Route to</span>
|
||||||
|
<select value={rule.outbound} onChange={(event) => updateRule(rule.id, { outbound: event.target.value })}>
|
||||||
|
<option value="direct">direct</option>
|
||||||
|
<option value="vpn">vpn</option>
|
||||||
|
<option value="block">block</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="rule-fields">
|
||||||
|
<label className="field">
|
||||||
|
<span>Domains exact</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.domains)}
|
||||||
|
onChange={(event) => updateRule(rule.id, { domains: textToList(event.target.value) })}
|
||||||
|
placeholder="riotgames.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Domain suffixes</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.domainSuffixes)}
|
||||||
|
onChange={(event) => updateRule(rule.id, { domainSuffixes: textToList(event.target.value) })}
|
||||||
|
placeholder={'leagueoflegends.com\nriotcdn.net'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>IP CIDR</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.ipCidrs)}
|
||||||
|
onChange={(event) => updateRule(rule.id, { ipCidrs: textToList(event.target.value) })}
|
||||||
|
placeholder="104.160.128.0/19"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Ports</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.ports)}
|
||||||
|
onChange={(event) => updateRule(rule.id, { ports: textToList(event.target.value) })}
|
||||||
|
placeholder={'5000\n5223'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rule-footer">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(rule.networks || []).includes('tcp')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const set = new Set(rule.networks || []);
|
||||||
|
event.target.checked ? set.add('tcp') : set.delete('tcp');
|
||||||
|
updateRule(rule.id, { networks: Array.from(set) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
tcp
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(rule.networks || []).includes('udp')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const set = new Set(rule.networks || []);
|
||||||
|
event.target.checked ? set.add('udp') : set.delete('udp');
|
||||||
|
updateRule(rule.id, { networks: Array.from(set) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
udp
|
||||||
|
</label>
|
||||||
|
<button className="danger-button" type="button" onClick={() => removeRule(rule.id)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />);
|
||||||
440
src/web/styles.css
Normal file
440
src/web/styles.css
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #07110d;
|
||||||
|
--panel: rgba(12, 28, 22, 0.86);
|
||||||
|
--panel-strong: rgba(18, 45, 34, 0.94);
|
||||||
|
--line: rgba(129, 255, 188, 0.2);
|
||||||
|
--text: #e8fff2;
|
||||||
|
--muted: #91b8a2;
|
||||||
|
--green: #7cffb2;
|
||||||
|
--amber: #ffd166;
|
||||||
|
--red: #ff6b6b;
|
||||||
|
--shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: 'IBM Plex Sans', sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 10%, rgba(124, 255, 178, 0.16), transparent 32rem),
|
||||||
|
radial-gradient(circle at 85% 0%, rgba(255, 209, 102, 0.1), transparent 26rem),
|
||||||
|
linear-gradient(140deg, #06100c 0%, #0a1711 48%, #050806 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1180px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--green);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 780px;
|
||||||
|
font-size: clamp(38px, 7vw, 76px);
|
||||||
|
line-height: 0.92;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
max-width: 740px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
min-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card strong,
|
||||||
|
.status-card small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 18px rgba(255, 209, 102, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 18px rgba(124, 255, 178, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-flow,
|
||||||
|
.details {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title.compact {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title span {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
color: #07110d;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(1, 8, 5, 0.72);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--green);
|
||||||
|
box-shadow: 0 0 0 4px rgba(124, 255, 178, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(1, 8, 5, 0.72);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--green);
|
||||||
|
box-shadow: 0 0 0 4px rgba(124, 255, 178, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-top: 14px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--green);
|
||||||
|
color: #07110d;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.apply {
|
||||||
|
background: linear-gradient(135deg, var(--green), #d8ff78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server,
|
||||||
|
.empty {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(1, 8, 5, 0.48);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server.active {
|
||||||
|
border-color: var(--green);
|
||||||
|
background: rgba(124, 255, 178, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server strong,
|
||||||
|
.server small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server small {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--red);
|
||||||
|
background: rgba(255, 107, 107, 0.1);
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.24);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-card,
|
||||||
|
.logs {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(1, 8, 5, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-card span {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-card p,
|
||||||
|
.logs p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs span {
|
||||||
|
color: var(--green);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button,
|
||||||
|
.danger-button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(1, 8, 5, 0.45);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button.solid {
|
||||||
|
background: var(--green);
|
||||||
|
color: #07110d;
|
||||||
|
border-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
border-color: rgba(255, 107, 107, 0.35);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-note {
|
||||||
|
margin: -6px 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(1, 8, 5, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-top,
|
||||||
|
.rule-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-top input {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.hero,
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-header,
|
||||||
|
.rule-top,
|
||||||
|
.rule-footer {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-actions,
|
||||||
|
.rule-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: '.',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
2
web/api/.gitignore
vendored
2
web/api/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"deleteOutDir": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6294
web/api/package-lock.json
generated
6294
web/api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "vpn-proxy-api",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "VPN-Proxy backend API",
|
|
||||||
"private": true,
|
|
||||||
"packageManager": "pnpm@10.32.1",
|
|
||||||
"scripts": {
|
|
||||||
"build": "nest build",
|
|
||||||
"start": "nest start",
|
|
||||||
"start:dev": "nest start --watch",
|
|
||||||
"start:prod": "node dist/main"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs/common": "^11.1.16",
|
|
||||||
"@nestjs/core": "^11.1.16",
|
|
||||||
"@nestjs/platform-express": "^11.1.16",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
|
||||||
"rxjs": "^7.8.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nestjs/cli": "^11.0.16",
|
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/node": "^24.0.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2738
web/api/pnpm-lock.yaml
generated
2738
web/api/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ProxyModule } from './proxy/proxy.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [ProxyModule],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// After compilation: dist/config/config.js
|
|
||||||
// __dirname = <web/api>/dist/config
|
|
||||||
// Go up: dist/config -> dist -> api -> web -> project root
|
|
||||||
const API_DIR = path.resolve(__dirname, '..', '..');
|
|
||||||
const WEB_DIR = path.resolve(API_DIR, '..');
|
|
||||||
const BASE_DIR = path.resolve(WEB_DIR, '..');
|
|
||||||
const DATA_DIR = path.join(BASE_DIR, 'data');
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
port: parseInt(process.env.PORT || '3456', 10),
|
|
||||||
proxyPort: parseInt(process.env.PROXY_PORT || '8080', 10),
|
|
||||||
reloadPort: parseInt(process.env.RELOAD_PORT || '9090', 10),
|
|
||||||
proxyBindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
|
|
||||||
appName: 'VPN-Proxy-Control by Dokril',
|
|
||||||
|
|
||||||
webDir: WEB_DIR,
|
|
||||||
baseDir: BASE_DIR,
|
|
||||||
dataDir: DATA_DIR,
|
|
||||||
configFile: path.join(DATA_DIR, 'client.json'),
|
|
||||||
hwidFile: path.join(DATA_DIR, 'hwid'),
|
|
||||||
subscriptionFile: path.join(DATA_DIR, 'subscription.json'),
|
|
||||||
fallbackFile: path.join(DATA_DIR, 'fallback.json'),
|
|
||||||
proxyEnabledFile: path.join(DATA_DIR, 'proxy_enabled.json'),
|
|
||||||
startTimeFile: path.join(DATA_DIR, 'start_time.json'),
|
|
||||||
|
|
||||||
defaultFallback: {
|
|
||||||
enabled: false,
|
|
||||||
host: '192.168.50.111',
|
|
||||||
port: 8080,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { AppModule } from './app.module';
|
|
||||||
import { config } from './config/config';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
|
||||||
|
|
||||||
// Serve static files from web/static/
|
|
||||||
app.useStaticAssets(path.join(config.webDir, 'static'), {
|
|
||||||
prefix: '/static/',
|
|
||||||
});
|
|
||||||
|
|
||||||
app.enableCors();
|
|
||||||
|
|
||||||
await app.listen(config.port);
|
|
||||||
console.log(`[WebUI] Server started on port ${config.port}`);
|
|
||||||
console.log(
|
|
||||||
`[WebUI] Open http://localhost:${config.port} in your browser`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bootstrap();
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { NetworkService } from './network.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [NetworkService],
|
|
||||||
exports: [NetworkService],
|
|
||||||
})
|
|
||||||
export class NetworkModule {}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as http from 'http';
|
|
||||||
import * as tls from 'tls';
|
|
||||||
import { config } from '../config/config';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NetworkService {
|
|
||||||
measureTcpLatency(
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
timeout = 2000,
|
|
||||||
): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const start = Date.now();
|
|
||||||
const socket = new net.Socket();
|
|
||||||
socket.setTimeout(timeout);
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
const latency = Date.now() - start;
|
|
||||||
socket.destroy();
|
|
||||||
resolve(latency);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.connect(port, host);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async measureProxyPerformance(
|
|
||||||
enableSpeedTest = false,
|
|
||||||
): Promise<Record<string, any>> {
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
|
|
||||||
// 1. Measure Latency
|
|
||||||
try {
|
|
||||||
const start = Date.now();
|
|
||||||
await this.httpViaProxy('http://www.gstatic.com/generate_204', {
|
|
||||||
headers: { 'User-Agent': 'singbox-test' },
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
result.latency = `${Date.now() - start}ms`;
|
|
||||||
} catch {
|
|
||||||
result.latency = 'Error';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get Public IP (IPv4)
|
|
||||||
try {
|
|
||||||
const ipRes = await this.httpViaProxy('http://v4.ident.me', {
|
|
||||||
headers: { 'User-Agent': 'curl/7.68.0' },
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
result.ip = ipRes.body.trim();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const ipRes = await this.httpViaProxy('http://api.ipify.org', {
|
|
||||||
headers: { 'User-Agent': 'curl/7.68.0' },
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
result.ip = ipRes.body.trim();
|
|
||||||
} catch {
|
|
||||||
result.ip = 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Speed test
|
|
||||||
if (enableSpeedTest) {
|
|
||||||
const testFiles = [
|
|
||||||
{ url: 'https://speedtest.selectel.ru/100MB', sizeMb: 100 },
|
|
||||||
{ url: 'https://speedtest.selectel.ru/1GB', sizeMb: 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
let speedMbps = 0;
|
|
||||||
|
|
||||||
for (const testFile of testFiles) {
|
|
||||||
try {
|
|
||||||
console.log(`[WebUI] Testing speed with: ${testFile.url}`);
|
|
||||||
const start = Date.now();
|
|
||||||
const maxBytes = 25 * 1024 * 1024;
|
|
||||||
const minDurationMs = 2000;
|
|
||||||
|
|
||||||
const downloaded = await this.downloadViaProxy(
|
|
||||||
testFile.url,
|
|
||||||
maxBytes,
|
|
||||||
minDurationMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
const duration = (Date.now() - start) / 1000;
|
|
||||||
if (duration > 0.1 && downloaded > 0) {
|
|
||||||
speedMbps =
|
|
||||||
Math.round(
|
|
||||||
((downloaded * 8) / (1000 * 1000) / duration) * 10,
|
|
||||||
) / 10;
|
|
||||||
console.log(
|
|
||||||
`[WebUI] Speed test: downloaded ${(downloaded / (1024 * 1024)).toFixed(1)}MB in ${duration.toFixed(1)}s = ${speedMbps} Mbps`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(
|
|
||||||
`[WebUI] Speed test failed for ${testFile.url}: ${e}`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.speed = `${speedMbps} Mbps`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Send HTTP request through local HTTP proxy (for http:// URLs) */
|
|
||||||
private httpViaProxy(
|
|
||||||
targetUrl: string,
|
|
||||||
options: {
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
timeout?: number;
|
|
||||||
} = {},
|
|
||||||
): Promise<{ statusCode: number; body: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parsed = new URL(targetUrl);
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: config.proxyPort,
|
|
||||||
path: targetUrl,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Host: parsed.host,
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
timeout: options.timeout || 5000,
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
|
||||||
res.on('end', () =>
|
|
||||||
resolve({ statusCode: res.statusCode, body }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.on('timeout', () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error('Timeout'));
|
|
||||||
});
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Download through proxy via CONNECT tunnel (for https:// URLs) */
|
|
||||||
private downloadViaProxy(
|
|
||||||
targetUrl: string,
|
|
||||||
maxBytes: number,
|
|
||||||
minDurationMs: number,
|
|
||||||
): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parsed = new URL(targetUrl);
|
|
||||||
const isHttps = parsed.protocol === 'https:';
|
|
||||||
|
|
||||||
if (!isHttps) {
|
|
||||||
// HTTP download through proxy
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: config.proxyPort,
|
|
||||||
path: targetUrl,
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Host: parsed.host },
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
let downloaded = 0;
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
res.on('data', (chunk: Buffer) => {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
const elapsed = Date.now() - start;
|
|
||||||
if (
|
|
||||||
downloaded >= maxBytes ||
|
|
||||||
(elapsed >= minDurationMs && downloaded >= 2 * 1024 * 1024)
|
|
||||||
) {
|
|
||||||
res.destroy();
|
|
||||||
resolve(downloaded);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('end', () => resolve(downloaded));
|
|
||||||
res.on('error', reject);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPS: use CONNECT tunnel through proxy
|
|
||||||
const proxyReq = http.request({
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: config.proxyPort,
|
|
||||||
method: 'CONNECT',
|
|
||||||
path: `${parsed.hostname}:${parsed.port || 443}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resolved = false;
|
|
||||||
const finish = (bytes: number) => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
resolve(bytes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const fail = (err: Error) => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proxyReq.on('connect', (proxyRes, socket) => {
|
|
||||||
if (proxyRes.statusCode !== 200) {
|
|
||||||
socket.destroy();
|
|
||||||
fail(new Error(`CONNECT failed: ${proxyRes.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tlsSocket = tls.connect(
|
|
||||||
{ socket, servername: parsed.hostname },
|
|
||||||
() => {
|
|
||||||
// Send raw HTTP request over TLS tunnel
|
|
||||||
tlsSocket.write(
|
|
||||||
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\n` +
|
|
||||||
`Host: ${parsed.hostname}\r\n` +
|
|
||||||
`User-Agent: singbox-speedtest\r\n` +
|
|
||||||
`Connection: close\r\n\r\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let downloaded = 0;
|
|
||||||
let headersParsed = false;
|
|
||||||
let headerBuffer = '';
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
tlsSocket.on('data', (chunk: Buffer) => {
|
|
||||||
if (!headersParsed) {
|
|
||||||
headerBuffer += chunk.toString('binary');
|
|
||||||
const headerEnd = headerBuffer.indexOf('\r\n\r\n');
|
|
||||||
if (headerEnd !== -1) {
|
|
||||||
headersParsed = true;
|
|
||||||
const bodyStart = headerEnd + 4;
|
|
||||||
downloaded +=
|
|
||||||
Buffer.byteLength(headerBuffer.slice(bodyStart), 'binary');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = Date.now() - start;
|
|
||||||
if (
|
|
||||||
downloaded >= maxBytes ||
|
|
||||||
(elapsed >= minDurationMs && downloaded >= 2 * 1024 * 1024)
|
|
||||||
) {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
finish(downloaded);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('end', () => finish(downloaded));
|
|
||||||
tlsSocket.on('error', fail);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
tlsSocket.on('error', fail);
|
|
||||||
});
|
|
||||||
|
|
||||||
proxyReq.on('error', fail);
|
|
||||||
proxyReq.setTimeout(30000, () => {
|
|
||||||
proxyReq.destroy();
|
|
||||||
fail(new Error('CONNECT timeout'));
|
|
||||||
});
|
|
||||||
proxyReq.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Response } from 'express';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { config } from '../config/config';
|
|
||||||
import { StorageService } from '../storage/storage.service';
|
|
||||||
import { VlessService } from '../vless/vless.service';
|
|
||||||
import { NetworkService } from '../network/network.service';
|
|
||||||
import { ProxyService } from './proxy.service';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class ProxyController {
|
|
||||||
constructor(
|
|
||||||
private readonly storage: StorageService,
|
|
||||||
private readonly vless: VlessService,
|
|
||||||
private readonly network: NetworkService,
|
|
||||||
private readonly proxyService: ProxyService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// --- Static / Index ---
|
|
||||||
|
|
||||||
@Get('/')
|
|
||||||
serveIndex(@Res() res: Response) {
|
|
||||||
const indexPath = path.join(config.webDir, 'index.html');
|
|
||||||
if (!fs.existsSync(indexPath)) {
|
|
||||||
return res.status(404).send('index.html not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let content = fs.readFileSync(indexPath, 'utf-8');
|
|
||||||
const normalizedWebDir = path.resolve(config.webDir);
|
|
||||||
|
|
||||||
content = content.replace(
|
|
||||||
/<!-- include "([^"]+)" -->/g,
|
|
||||||
(_, includePath: string) => {
|
|
||||||
const fullPath = path.resolve(normalizedWebDir, includePath);
|
|
||||||
// Path traversal protection
|
|
||||||
if (
|
|
||||||
!fullPath.startsWith(normalizedWebDir + path.sep) &&
|
|
||||||
fullPath !== normalizedWebDir
|
|
||||||
) {
|
|
||||||
return `<!-- Include failed: ${includePath} -->`;
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
||||||
return `<!-- Include failed: ${includePath} -->`;
|
|
||||||
}
|
|
||||||
return fs.readFileSync(fullPath, 'utf-8');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
res.type('html').send(content);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).send(`Error serving index: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Status ---
|
|
||||||
|
|
||||||
@Get('/status')
|
|
||||||
getStatus() {
|
|
||||||
const configExists = this.storage.configFileExists();
|
|
||||||
let currentTag: string | null = null;
|
|
||||||
let currentServer: string | null = null;
|
|
||||||
const proxyEnabled = this.storage.loadProxyEnabled();
|
|
||||||
|
|
||||||
if (configExists) {
|
|
||||||
try {
|
|
||||||
const cfg = this.storage.readConfigFile();
|
|
||||||
for (const outbound of cfg.outbounds || []) {
|
|
||||||
if (outbound.type === 'vless') {
|
|
||||||
currentTag = outbound.tag || 'unknown';
|
|
||||||
currentServer = outbound.server || 'unknown';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
active: configExists && proxyEnabled,
|
|
||||||
tag: currentTag,
|
|
||||||
server: currentServer,
|
|
||||||
proxyPort: config.proxyPort,
|
|
||||||
proxyEnabled,
|
|
||||||
startTime:
|
|
||||||
configExists && proxyEnabled ? this.storage.loadStartTime() : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Subscription ---
|
|
||||||
|
|
||||||
@Get('/subscription')
|
|
||||||
getSubscription() {
|
|
||||||
const sub = this.storage.loadSubscription();
|
|
||||||
if (sub) {
|
|
||||||
return {
|
|
||||||
saved: true,
|
|
||||||
url: sub.url,
|
|
||||||
selectedServer: sub.selectedServer,
|
|
||||||
userInfo: sub.userInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { saved: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Connection Test ---
|
|
||||||
|
|
||||||
@Get('/test-connection')
|
|
||||||
async testConnection(@Query('speed') speed?: string) {
|
|
||||||
const enableSpeed = speed?.toLowerCase() === 'true';
|
|
||||||
return this.network.measureProxyPerformance(enableSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fallback Config ---
|
|
||||||
|
|
||||||
@Get('/fallback-config')
|
|
||||||
getFallbackConfig() {
|
|
||||||
const fallback = this.storage.loadFallbackConfig();
|
|
||||||
return {
|
|
||||||
enabled: fallback.enabled ?? false,
|
|
||||||
host: fallback.host ?? '192.168.50.111',
|
|
||||||
port: fallback.port ?? 8080,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('/fallback-config')
|
|
||||||
saveFallbackConfig(@Body() body: any) {
|
|
||||||
const enabled = body.enabled ?? false;
|
|
||||||
const host = (body.host || '').trim();
|
|
||||||
const port = parseInt(body.port, 10) || 8080;
|
|
||||||
|
|
||||||
if (enabled && !host) {
|
|
||||||
throw new HttpException(
|
|
||||||
{ success: false, error: 'Host is required' },
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.storage.saveFallbackConfig(enabled, host, port);
|
|
||||||
const regenerated = this.proxyService.regenerateCurrentConfig();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Fallback config saved',
|
|
||||||
regenerated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Active Proxy ---
|
|
||||||
|
|
||||||
@Get('/active-proxy')
|
|
||||||
async getActiveProxy() {
|
|
||||||
return this.proxyService.getActiveProxy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Proxy Enabled ---
|
|
||||||
|
|
||||||
@Get('/proxy-enabled')
|
|
||||||
getProxyEnabled() {
|
|
||||||
return { enabled: this.storage.loadProxyEnabled() };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('/proxy-enabled')
|
|
||||||
setProxyEnabled(@Body() body: any) {
|
|
||||||
const enabled = body.enabled ?? true;
|
|
||||||
this.storage.saveProxyEnabled(enabled);
|
|
||||||
|
|
||||||
let regenerated = false;
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
regenerated = this.proxyService.regenerateCurrentConfig();
|
|
||||||
if (regenerated) {
|
|
||||||
this.storage.saveStartTime(Date.now() / 1000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
regenerated = this.proxyService.applyDirectConfig();
|
|
||||||
this.storage.saveStartTime(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, enabled, regenerated };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Apply VLESS URL ---
|
|
||||||
|
|
||||||
@Post('/apply')
|
|
||||||
applyConfig(@Body() body: any) {
|
|
||||||
const url = (body.url || '').trim();
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new HttpException(
|
|
||||||
{ success: false, error: 'URL не указан' },
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url.startsWith('vless://')) {
|
|
||||||
throw new HttpException(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: 'Неверный формат. Поддерживаются только vless:// ссылки',
|
|
||||||
},
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let vlessParams;
|
|
||||||
try {
|
|
||||||
vlessParams = this.vless.parseVlessUrl(url);
|
|
||||||
} catch (e: any) {
|
|
||||||
throw new HttpException(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: `Ошибка парсинга URL: ${e.message}`,
|
|
||||||
},
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = this.vless.generateVlessConfig(vlessParams);
|
|
||||||
this.storage.writeConfigFile(cfg);
|
|
||||||
this.proxyService.triggerReload();
|
|
||||||
this.storage.saveStartTime(Date.now() / 1000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Конфигурация '${vlessParams.tag}' успешно применена!`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fetch Subscription ---
|
|
||||||
|
|
||||||
@Post('/fetch-subscription')
|
|
||||||
async fetchSubscription(@Body() body: any) {
|
|
||||||
const url = (body.url || '').trim();
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new HttpException(
|
|
||||||
{ success: false, error: 'URL подписки не указан' },
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.proxyService.fetchSubscriptionFromUrl(url);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new HttpException(result, HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Apply Subscription ---
|
|
||||||
|
|
||||||
@Post('/apply-subscription')
|
|
||||||
applySubscription(@Body() body: any) {
|
|
||||||
const subConfig = body.config;
|
|
||||||
const selectedTag = body.selectedServer;
|
|
||||||
const subUrl = body.subUrl;
|
|
||||||
const userInfo = body.userInfo;
|
|
||||||
|
|
||||||
if (!subConfig) {
|
|
||||||
throw new HttpException(
|
|
||||||
{ success: false, error: 'Конфигурация не указана' },
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTag) {
|
|
||||||
throw new HttpException(
|
|
||||||
{ success: false, error: 'Сервер не выбран' },
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = this.proxyService.applySubscriptionConfig(
|
|
||||||
subConfig,
|
|
||||||
selectedTag,
|
|
||||||
subUrl,
|
|
||||||
userInfo,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new HttpException(result, HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Ping Target ---
|
|
||||||
|
|
||||||
@Post('/ping-target')
|
|
||||||
async pingTarget(@Body() body: any) {
|
|
||||||
const server = body.server;
|
|
||||||
const port = parseInt(body.port, 10) || 443;
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
throw new HttpException(
|
|
||||||
{ error: 'No server specified' },
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latency = await this.network.measureTcpLatency(server, port);
|
|
||||||
return { latency };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { StorageModule } from '../storage/storage.module';
|
|
||||||
import { VlessModule } from '../vless/vless.module';
|
|
||||||
import { NetworkModule } from '../network/network.module';
|
|
||||||
import { ProxyController } from './proxy.controller';
|
|
||||||
import { ProxyService } from './proxy.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [StorageModule, VlessModule, NetworkModule],
|
|
||||||
controllers: [ProxyController],
|
|
||||||
providers: [ProxyService],
|
|
||||||
})
|
|
||||||
export class ProxyModule {}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import * as http from 'http';
|
|
||||||
import * as https from 'https';
|
|
||||||
import { config } from '../config/config';
|
|
||||||
import { StorageService } from '../storage/storage.service';
|
|
||||||
import { VlessService } from '../vless/vless.service';
|
|
||||||
import { NetworkService } from '../network/network.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ProxyService {
|
|
||||||
constructor(
|
|
||||||
private readonly storage: StorageService,
|
|
||||||
private readonly vless: VlessService,
|
|
||||||
private readonly network: NetworkService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
triggerReload(): void {
|
|
||||||
try {
|
|
||||||
const req = http.get(
|
|
||||||
`http://127.0.0.1:${config.reloadPort}/reload`,
|
|
||||||
{ timeout: 3000 },
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
req.on('error', () => {});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Regenerate current config with updated fallback settings */
|
|
||||||
regenerateCurrentConfig(): boolean {
|
|
||||||
if (!this.storage.configFileExists()) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cfg = this.storage.readConfigFile();
|
|
||||||
const outbounds: any[] = cfg.outbounds || [];
|
|
||||||
|
|
||||||
let vpnOutbound: any = null;
|
|
||||||
const utilityOutbounds: any[] = [];
|
|
||||||
|
|
||||||
for (const outbound of outbounds) {
|
|
||||||
if (
|
|
||||||
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
|
|
||||||
outbound.type,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
vpnOutbound = outbound;
|
|
||||||
} else if (['direct', 'block', 'dns'].includes(outbound.type)) {
|
|
||||||
utilityOutbounds.push(outbound);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vpnOutbound) return false;
|
|
||||||
|
|
||||||
const selectedTag = vpnOutbound.tag;
|
|
||||||
const fallback = this.storage.loadFallbackConfig();
|
|
||||||
const fallbackEnabled = fallback.enabled;
|
|
||||||
const fallbackHost = fallback.host;
|
|
||||||
const fallbackPort = fallback.port;
|
|
||||||
|
|
||||||
const finalOutbounds: any[] = [];
|
|
||||||
let finalTag = selectedTag;
|
|
||||||
|
|
||||||
if (fallbackEnabled && fallbackHost) {
|
|
||||||
finalOutbounds.push({
|
|
||||||
type: 'urltest',
|
|
||||||
tag: 'auto-select',
|
|
||||||
outbounds: ['fallback-proxy', selectedTag],
|
|
||||||
url: 'http://www.gstatic.com/generate_204',
|
|
||||||
interval: '30s',
|
|
||||||
tolerance: 9999,
|
|
||||||
});
|
|
||||||
|
|
||||||
finalOutbounds.push({
|
|
||||||
type: 'http',
|
|
||||||
tag: 'fallback-proxy',
|
|
||||||
server: fallbackHost,
|
|
||||||
server_port: fallbackPort,
|
|
||||||
});
|
|
||||||
|
|
||||||
finalTag = 'auto-select';
|
|
||||||
}
|
|
||||||
|
|
||||||
finalOutbounds.push(vpnOutbound);
|
|
||||||
finalOutbounds.push(...utilityOutbounds);
|
|
||||||
|
|
||||||
cfg.outbounds = finalOutbounds;
|
|
||||||
cfg.route.final = finalTag;
|
|
||||||
|
|
||||||
this.storage.writeConfigFile(cfg);
|
|
||||||
this.triggerReload();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[WebUI] Failed to regenerate config: ${e}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate and apply direct config (bypass proxy) */
|
|
||||||
applyDirectConfig(): boolean {
|
|
||||||
try {
|
|
||||||
const directCfg = this.vless.generateDirectConfig();
|
|
||||||
this.storage.writeConfigFile(directCfg);
|
|
||||||
this.triggerReload();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[WebUI] Failed to generate direct config: ${e}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get active proxy chain info */
|
|
||||||
async getActiveProxy(): Promise<Record<string, any>> {
|
|
||||||
const result: Record<string, any> = {
|
|
||||||
configured: false,
|
|
||||||
fallbackEnabled: false,
|
|
||||||
fallbackHost: null,
|
|
||||||
vpnTag: null,
|
|
||||||
vpnServer: null,
|
|
||||||
activeOutbound: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!this.storage.configFileExists()) return result;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cfg = this.storage.readConfigFile();
|
|
||||||
const outbounds: any[] = cfg.outbounds || [];
|
|
||||||
const routeFinal = cfg.route?.final;
|
|
||||||
|
|
||||||
result.configured = true;
|
|
||||||
|
|
||||||
for (const outbound of outbounds) {
|
|
||||||
const outType = outbound.type;
|
|
||||||
|
|
||||||
if (outType === 'urltest') {
|
|
||||||
result.fallbackEnabled = true;
|
|
||||||
} else if (outType === 'http' && outbound.tag === 'fallback-proxy') {
|
|
||||||
result.fallbackHost = `${outbound.server}:${outbound.server_port}`;
|
|
||||||
} else if (
|
|
||||||
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
|
|
||||||
outType,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
result.vpnTag = outbound.tag;
|
|
||||||
result.vpnServer = outbound.server;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.activeOutbound = routeFinal;
|
|
||||||
|
|
||||||
// Check fallback proxy reachability
|
|
||||||
if (result.fallbackEnabled && result.fallbackHost) {
|
|
||||||
try {
|
|
||||||
const [host, portStr] = result.fallbackHost.split(':');
|
|
||||||
const latency = await this.network.measureTcpLatency(
|
|
||||||
host,
|
|
||||||
parseInt(portStr, 10),
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
result.fallbackReachable = latency > 0;
|
|
||||||
result.fallbackLatency = latency > 0 ? latency : null;
|
|
||||||
} catch {
|
|
||||||
result.fallbackReachable = false;
|
|
||||||
result.fallbackLatency = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
result.error = String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch subscription from URL */
|
|
||||||
async fetchSubscriptionFromUrl(url: string): Promise<Record<string, any>> {
|
|
||||||
// Validate URL scheme (SSRF protection)
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(url);
|
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Недопустимый протокол (только http/https)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: 'Некорректный URL' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sysInfo = this.storage.getSystemInfo();
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'User-Agent': 'singbox',
|
|
||||||
'x-hwid': this.storage.getHwid(),
|
|
||||||
'x-device-os': sysInfo.os,
|
|
||||||
'x-ver-os': sysInfo.version,
|
|
||||||
'x-device-model': config.appName,
|
|
||||||
};
|
|
||||||
|
|
||||||
let configText: string;
|
|
||||||
let userInfo: Record<string, number> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.fetchUrl(url, headers);
|
|
||||||
|
|
||||||
configText = response.body;
|
|
||||||
|
|
||||||
// Parse subscription-userinfo header
|
|
||||||
const userInfoHeader =
|
|
||||||
response.headers['subscription-userinfo'] || '';
|
|
||||||
if (userInfoHeader) {
|
|
||||||
const parts = (
|
|
||||||
Array.isArray(userInfoHeader)
|
|
||||||
? userInfoHeader[0]
|
|
||||||
: userInfoHeader
|
|
||||||
).split(';');
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.includes('=')) {
|
|
||||||
const [key, value] = part.trim().split('=', 2);
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
userInfo[key] = num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
const msg = e.message || String(e);
|
|
||||||
if (msg.includes('HTTP Error')) {
|
|
||||||
return { success: false, error: `Ошибка HTTP: ${msg}` };
|
|
||||||
}
|
|
||||||
return { success: false, error: `Ошибка подключения: ${msg}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse as JSON first
|
|
||||||
let parsedConfig: any = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsedConfig = JSON.parse(configText);
|
|
||||||
} catch {
|
|
||||||
// Not JSON — try Base64 decode or plain VLESS links
|
|
||||||
let content = configText.trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (/^[A-Za-z0-9+/=\s]+$/.test(content)) {
|
|
||||||
const decoded = Buffer.from(content, 'base64').toString('utf-8');
|
|
||||||
content = decoded;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Parse VLESS links
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const vlessLinks = lines
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.startsWith('vless://'));
|
|
||||||
|
|
||||||
if (vlessLinks.length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Не найдены VLESS ссылки в ответе',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const outbounds: any[] = [];
|
|
||||||
for (const link of vlessLinks) {
|
|
||||||
try {
|
|
||||||
const params = this.vless.parseVlessUrl(link);
|
|
||||||
outbounds.push({
|
|
||||||
type: 'vless',
|
|
||||||
tag: params.tag,
|
|
||||||
server: params.server,
|
|
||||||
server_port: params.server_port,
|
|
||||||
uuid: params.uuid,
|
|
||||||
flow: params.flow,
|
|
||||||
tls: {
|
|
||||||
enabled: true,
|
|
||||||
server_name: params.server_name,
|
|
||||||
utls: {
|
|
||||||
enabled: true,
|
|
||||||
fingerprint: params.fingerprint,
|
|
||||||
},
|
|
||||||
reality: {
|
|
||||||
enabled: true,
|
|
||||||
public_key: params.public_key,
|
|
||||||
short_id: params.short_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
packet_encoding: 'xudp',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[WebUI] Failed to parse VLESS link: ${e}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outbounds.length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Не удалось распарсить VLESS ссылки',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedConfig = { outbounds };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract servers
|
|
||||||
const outbounds: any[] = parsedConfig.outbounds || [];
|
|
||||||
const servers: any[] = [];
|
|
||||||
|
|
||||||
for (const outbound of outbounds) {
|
|
||||||
if (
|
|
||||||
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
|
|
||||||
outbound.type,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
servers.push({
|
|
||||||
tag: outbound.tag || 'unknown',
|
|
||||||
type: outbound.type,
|
|
||||||
server: outbound.server || 'unknown',
|
|
||||||
server_port: outbound.server_port || 443,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (servers.length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Серверы не найдены в подписке',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
servers,
|
|
||||||
config: parsedConfig,
|
|
||||||
userInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply subscription config with selected server */
|
|
||||||
applySubscriptionConfig(
|
|
||||||
subConfig: any,
|
|
||||||
selectedTag: string,
|
|
||||||
subUrl?: string,
|
|
||||||
userInfoData?: any,
|
|
||||||
): { success: boolean; message?: string; error?: string } {
|
|
||||||
const outbounds: any[] = subConfig.outbounds || [];
|
|
||||||
const newOutbounds: any[] = [];
|
|
||||||
let selectedOutbound: any = null;
|
|
||||||
|
|
||||||
for (const outbound of outbounds) {
|
|
||||||
if (outbound.tag === selectedTag) {
|
|
||||||
selectedOutbound = outbound;
|
|
||||||
} else if (['direct', 'block', 'dns'].includes(outbound.type)) {
|
|
||||||
newOutbounds.push(outbound);
|
|
||||||
}
|
|
||||||
// Skip selector type
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedOutbound) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Сервер '${selectedTag}' не найден`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load fallback configuration
|
|
||||||
const fallback = this.storage.loadFallbackConfig();
|
|
||||||
const finalOutbounds: any[] = [];
|
|
||||||
let finalTag = selectedTag;
|
|
||||||
|
|
||||||
if (fallback.enabled && fallback.host) {
|
|
||||||
finalOutbounds.push({
|
|
||||||
type: 'urltest',
|
|
||||||
tag: 'auto-select',
|
|
||||||
outbounds: ['fallback-proxy', selectedTag],
|
|
||||||
url: 'http://www.gstatic.com/generate_204',
|
|
||||||
interval: '30s',
|
|
||||||
tolerance: 9999,
|
|
||||||
});
|
|
||||||
|
|
||||||
finalOutbounds.push({
|
|
||||||
type: 'http',
|
|
||||||
tag: 'fallback-proxy',
|
|
||||||
server: fallback.host,
|
|
||||||
server_port: fallback.port,
|
|
||||||
});
|
|
||||||
|
|
||||||
finalTag = 'auto-select';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add selected VPN server
|
|
||||||
finalOutbounds.push(selectedOutbound);
|
|
||||||
// Add utility outbounds
|
|
||||||
finalOutbounds.push(...newOutbounds);
|
|
||||||
|
|
||||||
// Build config
|
|
||||||
subConfig.dns = { independent_cache: true };
|
|
||||||
delete subConfig.platform;
|
|
||||||
delete subConfig.experimental;
|
|
||||||
|
|
||||||
subConfig.inbounds = [
|
|
||||||
{
|
|
||||||
tag: 'mixed-in',
|
|
||||||
type: 'mixed',
|
|
||||||
sniff: true,
|
|
||||||
users: [],
|
|
||||||
listen: config.proxyBindIp,
|
|
||||||
listen_port: config.proxyPort,
|
|
||||||
set_system_proxy: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
subConfig.outbounds = finalOutbounds;
|
|
||||||
subConfig.route = {
|
|
||||||
final: finalTag,
|
|
||||||
auto_detect_interface: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write config
|
|
||||||
this.storage.writeConfigFile(subConfig);
|
|
||||||
|
|
||||||
// Save subscription for persistence
|
|
||||||
if (subUrl) {
|
|
||||||
this.storage.saveSubscription(subUrl, selectedTag, userInfoData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload sing-box
|
|
||||||
this.triggerReload();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Сервер '${selectedTag}' успешно применён!`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch a URL directly (not through proxy) with redirect support */
|
|
||||||
private fetchUrl(
|
|
||||||
url: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
maxRedirects = 5,
|
|
||||||
): Promise<{
|
|
||||||
body: string;
|
|
||||||
headers: Record<string, string | string[]>;
|
|
||||||
}> {
|
|
||||||
if (maxRedirects <= 0) {
|
|
||||||
return Promise.reject(new Error('Too many redirects'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(url);
|
|
||||||
} catch {
|
|
||||||
reject(new Error('Invalid URL'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = parsed.protocol === 'https:' ? https : http;
|
|
||||||
|
|
||||||
const req = mod.request(
|
|
||||||
{
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
port:
|
|
||||||
parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
||||||
path: parsed.pathname + parsed.search,
|
|
||||||
method: 'GET',
|
|
||||||
headers,
|
|
||||||
timeout: 15000,
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
// Handle redirects
|
|
||||||
if (
|
|
||||||
res.statusCode >= 300 &&
|
|
||||||
res.statusCode < 400 &&
|
|
||||||
res.headers.location
|
|
||||||
) {
|
|
||||||
this.fetchUrl(res.headers.location, headers, maxRedirects - 1)
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
reject(new Error(`HTTP Error: ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const body = Buffer.concat(chunks).toString('utf-8');
|
|
||||||
resolve({
|
|
||||||
body,
|
|
||||||
headers: res.headers as Record<string, string | string[]>,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.on('timeout', () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error('Timeout'));
|
|
||||||
});
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { StorageService } from './storage.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [StorageService],
|
|
||||||
exports: [StorageService],
|
|
||||||
})
|
|
||||||
export class StorageModule {}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import * as os from 'os';
|
|
||||||
import { config } from '../config/config';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class StorageService {
|
|
||||||
private ensureDataDir(): void {
|
|
||||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Subscription ---
|
|
||||||
|
|
||||||
saveSubscription(
|
|
||||||
url: string,
|
|
||||||
selectedServer?: string,
|
|
||||||
userInfo?: any,
|
|
||||||
): void {
|
|
||||||
this.ensureDataDir();
|
|
||||||
const data = { url, selectedServer, userInfo };
|
|
||||||
fs.writeFileSync(
|
|
||||||
config.subscriptionFile,
|
|
||||||
JSON.stringify(data, null, 2),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSubscription(): any {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(config.subscriptionFile)) {
|
|
||||||
return JSON.parse(fs.readFileSync(config.subscriptionFile, 'utf-8'));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fallback Config ---
|
|
||||||
|
|
||||||
saveFallbackConfig(enabled: boolean, host: string, port: number): void {
|
|
||||||
this.ensureDataDir();
|
|
||||||
const data = { enabled, host, port };
|
|
||||||
fs.writeFileSync(
|
|
||||||
config.fallbackFile,
|
|
||||||
JSON.stringify(data, null, 2),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFallbackConfig(): { enabled: boolean; host: string; port: number } {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(config.fallbackFile)) {
|
|
||||||
return JSON.parse(fs.readFileSync(config.fallbackFile, 'utf-8'));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return { ...config.defaultFallback };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Proxy Enabled ---
|
|
||||||
|
|
||||||
saveProxyEnabled(enabled: boolean): void {
|
|
||||||
this.ensureDataDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
config.proxyEnabledFile,
|
|
||||||
JSON.stringify({ enabled }),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProxyEnabled(): boolean {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(config.proxyEnabledFile)) {
|
|
||||||
const data = JSON.parse(
|
|
||||||
fs.readFileSync(config.proxyEnabledFile, 'utf-8'),
|
|
||||||
);
|
|
||||||
return data.enabled ?? true;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Start Time ---
|
|
||||||
|
|
||||||
saveStartTime(startTime: number): void {
|
|
||||||
this.ensureDataDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
config.startTimeFile,
|
|
||||||
JSON.stringify({ startTime }),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStartTime(): number {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(config.startTimeFile)) {
|
|
||||||
const data = JSON.parse(
|
|
||||||
fs.readFileSync(config.startTimeFile, 'utf-8'),
|
|
||||||
);
|
|
||||||
return data.startTime ?? 0;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HWID ---
|
|
||||||
|
|
||||||
getHwid(): string {
|
|
||||||
this.ensureDataDir();
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(config.hwidFile)) {
|
|
||||||
return fs.readFileSync(config.hwidFile, 'utf-8').trim();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
const hwid = crypto.randomBytes(8).toString('hex');
|
|
||||||
fs.writeFileSync(config.hwidFile, hwid, 'utf-8');
|
|
||||||
return hwid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- System Info ---
|
|
||||||
|
|
||||||
getSystemInfo(): { os: string; version: string } {
|
|
||||||
return {
|
|
||||||
os: os.platform(),
|
|
||||||
version: os.release(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config File ---
|
|
||||||
|
|
||||||
readConfigFile(): any {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(config.configFile)) {
|
|
||||||
return JSON.parse(fs.readFileSync(config.configFile, 'utf-8'));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeConfigFile(data: any): void {
|
|
||||||
this.ensureDataDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
config.configFile,
|
|
||||||
JSON.stringify(data, null, 2),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
configFileExists(): boolean {
|
|
||||||
return fs.existsSync(config.configFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { VlessService } from './vless.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [VlessService],
|
|
||||||
exports: [VlessService],
|
|
||||||
})
|
|
||||||
export class VlessModule {}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { config } from '../config/config';
|
|
||||||
|
|
||||||
export interface VlessParams {
|
|
||||||
uuid: string;
|
|
||||||
server: string;
|
|
||||||
server_port: number;
|
|
||||||
tag: string;
|
|
||||||
public_key: string;
|
|
||||||
short_id: string;
|
|
||||||
server_name: string;
|
|
||||||
fingerprint: string;
|
|
||||||
flow: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class VlessService {
|
|
||||||
parseVlessUrl(url: string): VlessParams {
|
|
||||||
if (!url.startsWith('vless://')) {
|
|
||||||
throw new Error('URL must start with vless://');
|
|
||||||
}
|
|
||||||
|
|
||||||
let urlNoScheme = url.slice(8);
|
|
||||||
|
|
||||||
// Split by fragment (#tag)
|
|
||||||
let tag = 'reality';
|
|
||||||
const hashIndex = urlNoScheme.indexOf('#');
|
|
||||||
if (hashIndex !== -1) {
|
|
||||||
tag = decodeURIComponent(urlNoScheme.slice(hashIndex + 1));
|
|
||||||
urlNoScheme = urlNoScheme.slice(0, hashIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by query (?)
|
|
||||||
const qIndex = urlNoScheme.indexOf('?');
|
|
||||||
if (qIndex === -1) {
|
|
||||||
throw new Error('Missing query parameters');
|
|
||||||
}
|
|
||||||
|
|
||||||
const uuidHostPort = urlNoScheme.slice(0, qIndex);
|
|
||||||
const queryString = urlNoScheme.slice(qIndex + 1);
|
|
||||||
|
|
||||||
// Parse UUID@host:port
|
|
||||||
const atIndex = uuidHostPort.indexOf('@');
|
|
||||||
if (atIndex === -1) {
|
|
||||||
throw new Error('Missing @ separator');
|
|
||||||
}
|
|
||||||
|
|
||||||
const uuid = uuidHostPort.slice(0, atIndex);
|
|
||||||
const hostPort = uuidHostPort.slice(atIndex + 1);
|
|
||||||
|
|
||||||
const lastColon = hostPort.lastIndexOf(':');
|
|
||||||
if (lastColon === -1) {
|
|
||||||
throw new Error('Missing port');
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = hostPort.slice(0, lastColon);
|
|
||||||
const port = parseInt(hostPort.slice(lastColon + 1), 10);
|
|
||||||
|
|
||||||
// Parse query parameters
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
for (const param of queryString.split('&')) {
|
|
||||||
const eqIndex = param.indexOf('=');
|
|
||||||
if (eqIndex !== -1) {
|
|
||||||
params[param.slice(0, eqIndex)] = decodeURIComponent(
|
|
||||||
param.slice(eqIndex + 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pbk = params.pbk || '';
|
|
||||||
const sid = params.sid || '';
|
|
||||||
const sni = params.sni || host;
|
|
||||||
const fp = params.fp || 'chrome';
|
|
||||||
const flow = params.flow || '';
|
|
||||||
|
|
||||||
if (!pbk || !sid) {
|
|
||||||
throw new Error('Missing required parameters: pbk or sid');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
uuid,
|
|
||||||
server: host,
|
|
||||||
server_port: port,
|
|
||||||
tag,
|
|
||||||
public_key: pbk,
|
|
||||||
short_id: sid,
|
|
||||||
server_name: sni,
|
|
||||||
fingerprint: fp,
|
|
||||||
flow,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
generateVlessConfig(vlessParams: VlessParams): any {
|
|
||||||
return {
|
|
||||||
dns: { independent_cache: true },
|
|
||||||
log: { level: 'debug', disabled: true, timestamp: true },
|
|
||||||
route: {
|
|
||||||
final: vlessParams.tag,
|
|
||||||
auto_detect_interface: true,
|
|
||||||
},
|
|
||||||
inbounds: [
|
|
||||||
{
|
|
||||||
tag: 'mixed-in',
|
|
||||||
type: 'mixed',
|
|
||||||
sniff: true,
|
|
||||||
users: [],
|
|
||||||
listen: '0.0.0.0',
|
|
||||||
listen_port: config.proxyPort,
|
|
||||||
set_system_proxy: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
outbounds: [
|
|
||||||
{
|
|
||||||
type: 'vless',
|
|
||||||
tag: vlessParams.tag,
|
|
||||||
server: vlessParams.server,
|
|
||||||
server_port: vlessParams.server_port,
|
|
||||||
flow: vlessParams.flow,
|
|
||||||
tls: {
|
|
||||||
enabled: true,
|
|
||||||
server_name: vlessParams.server_name,
|
|
||||||
reality: {
|
|
||||||
enabled: true,
|
|
||||||
public_key: vlessParams.public_key,
|
|
||||||
short_id: vlessParams.short_id,
|
|
||||||
},
|
|
||||||
utls: {
|
|
||||||
enabled: true,
|
|
||||||
fingerprint: vlessParams.fingerprint,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
uuid: vlessParams.uuid,
|
|
||||||
},
|
|
||||||
{ tag: 'direct', type: 'direct' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
generateDirectConfig(): any {
|
|
||||||
return {
|
|
||||||
dns: { independent_cache: true },
|
|
||||||
log: { level: 'debug', disabled: true, timestamp: true },
|
|
||||||
route: { final: 'direct', auto_detect_interface: true },
|
|
||||||
inbounds: [
|
|
||||||
{
|
|
||||||
tag: 'mixed-in',
|
|
||||||
type: 'mixed',
|
|
||||||
sniff: true,
|
|
||||||
users: [],
|
|
||||||
listen: '0.0.0.0',
|
|
||||||
listen_port: config.proxyPort,
|
|
||||||
set_system_proxy: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
outbounds: [{ tag: 'direct', type: 'direct' }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"declaration": true,
|
|
||||||
"removeComments": true,
|
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"target": "ES2021",
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"baseUrl": "./",
|
|
||||||
"incremental": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"types": ["node"],
|
|
||||||
"strictNullChecks": false,
|
|
||||||
"noImplicitAny": false,
|
|
||||||
"strictBindCallApply": false,
|
|
||||||
"forceConsistentCasingInFileNames": false,
|
|
||||||
"noFallthroughCasesInSwitch": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<!-- Connection Settings -->
|
|
||||||
<div class="bg-black border border-[#00ff41]/20 p-4 font-mono">
|
|
||||||
<div class="text-[11px] uppercase font-bold tracking-[0.3em] text-[#00ff41] mb-3 flex items-center gap-2">
|
|
||||||
<i data-lucide="plug" class="w-4 h-4"></i> Connection_Settings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-2">
|
|
||||||
<!-- HTTP Proxy -->
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-[#0a0a0a] border border-[#00ff41]/10">
|
|
||||||
<span class="text-[11px] uppercase opacity-50 text-[#00ff41] w-16">HTTP</span>
|
|
||||||
<input type="text" id="httpProxyUrl" readonly value="Loading..."
|
|
||||||
class="flex-grow bg-transparent text-sm text-[#00ff41]/80 focus:outline-none cursor-pointer font-mono"
|
|
||||||
title="Click to copy" />
|
|
||||||
<button onclick="copyToClipboard('httpProxyUrl', this)"
|
|
||||||
class="text-[10px] text-[#00ff41]/50 hover:text-[#00ff41] transition-colors uppercase px-2 py-1 border border-[#00ff41]/20">
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SOCKS5 Proxy -->
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-[#0a0a0a] border border-[#00ff41]/10">
|
|
||||||
<span class="text-[11px] uppercase opacity-50 text-[#00ff41] w-16">SOCKS5</span>
|
|
||||||
<input type="text" id="socks5ProxyUrl" readonly value="Loading..."
|
|
||||||
class="flex-grow bg-transparent text-sm text-[#00ff41]/80 focus:outline-none cursor-pointer font-mono"
|
|
||||||
title="Click to copy" />
|
|
||||||
<button onclick="copyToClipboard('socks5ProxyUrl', this)"
|
|
||||||
class="text-[10px] text-[#00ff41]/50 hover:text-[#00ff41] transition-colors uppercase px-2 py-1 border border-[#00ff41]/20">
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 text-[11px] opacity-40 text-[#00ff41] flex items-center gap-2">
|
|
||||||
<i data-lucide="info" class="w-3 h-3"></i>
|
|
||||||
Use these URLs in browser/app proxy settings
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<!-- Fallback Proxy Configuration (Expanded) -->
|
|
||||||
<div id="fallbackSection" class="flex flex-col bg-black border border-[#00ff41]/30 overflow-hidden font-mono">
|
|
||||||
<div class="bg-[#111] px-5 py-3 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
|
||||||
<span class="text-[11px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
|
||||||
<i data-lucide="git-branch" class="w-4 h-4"></i> Fallback_Proxy_Settings
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<!-- Enable/Disable Toggle -->
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<span id="fallbackToggleLabel" class="text-[11px] opacity-50 uppercase text-[#00ff41]">OFF</span>
|
|
||||||
<div class="relative">
|
|
||||||
<input type="checkbox" id="fallbackToggle" class="sr-only peer">
|
|
||||||
<div
|
|
||||||
class="w-10 h-5 bg-[#1a1a1a] border border-[#00ff41]/20 rounded-full peer-checked:bg-[#00ff41]/20 peer-checked:border-[#00ff41]/50 transition-all">
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute left-0.5 top-0.5 w-4 h-4 bg-[#00ff41]/30 rounded-full peer-checked:translate-x-5 peer-checked:bg-[#00ff41] transition-all">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<button id="saveFallbackBtn"
|
|
||||||
class="text-[11px] opacity-50 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase px-3 py-1 border border-[#00ff41]/20 hover:border-[#00ff41]/50">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
<!-- Host/Port inputs -->
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="text-[11px] opacity-50 uppercase text-[#00ff41] block mb-1">Host</label>
|
|
||||||
<input type="text" id="fallbackHost" placeholder="192.168.50.111"
|
|
||||||
class="w-full bg-[#0a0a0a] border border-[#00ff41]/20 p-3 text-sm text-[#00ff41] focus:outline-none focus:border-[#00ff41]/50 placeholder:text-[#00ff41]/20" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-[11px] opacity-50 uppercase text-[#00ff41] block mb-1">Port</label>
|
|
||||||
<input type="number" id="fallbackPort" placeholder="8080"
|
|
||||||
class="w-full bg-[#0a0a0a] border border-[#00ff41]/20 p-3 text-sm text-[#00ff41] focus:outline-none focus:border-[#00ff41]/50 placeholder:text-[#00ff41]/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="text-[11px] opacity-50 text-[#00ff41] flex items-start gap-2">
|
|
||||||
<i data-lucide="info" class="w-4 h-4 shrink-0 mt-0.5"></i>
|
|
||||||
<span>URLTest auto-selects fastest proxy. Re-apply subscription after changes.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div id="fallbackStatus" class="text-sm text-[#00ff41]/50 uppercase hidden"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!-- Footer -->
|
|
||||||
<footer class="z-30 bg-[#0d0d0d] border-t border-[#00ff41]/10 py-2 mt-auto">
|
|
||||||
<div
|
|
||||||
class="max-w-[1400px] mx-auto px-6 flex justify-between items-center text-[10px] uppercase tracking-[0.2em] opacity-40 text-[#00ff41]">
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<span>Core: 4.1.0-Release</span>
|
|
||||||
<span>Proxy: HTTP/8080</span>
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:flex gap-6">
|
|
||||||
<span>AES-256-GCM</span>
|
|
||||||
<span class="text-[#00ff41] opacity-100 font-bold tracking-normal">SESSION: <span
|
|
||||||
id="sessionId">...</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<!-- Header -->
|
|
||||||
<header class="z-30 border-b border-[#00ff41]/20 bg-black/90 backdrop-blur-md sticky top-0">
|
|
||||||
<div class="max-w-[1400px] mx-auto px-4 md:px-6 py-3 flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="relative p-1.5 border border-[#00ff41]/50 shadow-[0_0_10px_rgba(0,255,65,0.2)] bg-black">
|
|
||||||
<i data-lucide="terminal" class="w-5 h-5 animate-pulse text-[#00ff41]"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-base font-black tracking-[0.2em] uppercase text-[#00ff41]">
|
|
||||||
VPN<span class="text-white">_</span>CLIENT
|
|
||||||
</h1>
|
|
||||||
<p class="text-[10px] opacity-40 uppercase tracking-widest text-[#00ff41]">Secure Shell v4.2</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-6 text-[11px] uppercase">
|
|
||||||
<div class="flex flex-col items-end border-r border-[#00ff41]/20 pr-6">
|
|
||||||
<span class="opacity-30 text-[#00ff41]">Status</span>
|
|
||||||
<span id="headerStatus" class="text-white font-bold text-sm">STANDBY</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="opacity-30 text-[#00ff41]">Traffic_Used</span>
|
|
||||||
<span id="trafficValue" class="text-blue-400 font-bold text-sm">-- / --</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<!-- Terminal Logs -->
|
|
||||||
<div class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono min-h-[180px]">
|
|
||||||
<div class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
|
||||||
<span class="text-[11px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
|
||||||
<div class="w-1.5 h-1.5 bg-[#00ff41] rounded-full animate-ping"></div> Logs
|
|
||||||
</span>
|
|
||||||
<button id="clearLogs"
|
|
||||||
class="text-[10px] opacity-30 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase">Clear</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="logsContainer"
|
|
||||||
class="flex-grow p-3 overflow-y-auto custom-scrollbar text-[11px] space-y-1 opacity-80 font-mono">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span class="opacity-20 text-[#00ff41]">[SYSTEM]</span>
|
|
||||||
<span class="text-[#00ff41] animate-pulse">_</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<!-- Placeholder for World Map Component -->
|
|
||||||
<div class="hidden p-4 bg-black border border-[#00ff41]/30">
|
|
||||||
<div class="text-[11px] uppercase opacity-50 text-[#00ff41]">Global_Map_View // Not_Implemented</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<!-- Proxy Chain Visualization (Expanded) -->
|
|
||||||
<div id="proxyChainSection" class="bg-black border border-[#00ff41]/30 p-5 font-mono">
|
|
||||||
<div class="text-[11px] uppercase font-bold tracking-[0.3em] text-[#00ff41] mb-5 flex items-center gap-2">
|
|
||||||
<i data-lucide="git-branch" class="w-4 h-4"></i> Proxy_Chain_Visualization
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="proxyChain" class="flex items-stretch gap-4 text-sm justify-center py-4">
|
|
||||||
<!-- You -->
|
|
||||||
<div class="flex flex-col items-center justify-center gap-2">
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 rounded-full border-2 border-[#00ff41] flex items-center justify-center bg-[#00ff41]/10">
|
|
||||||
<i data-lucide="user" class="w-6 h-6 text-[#00ff41]"></i>
|
|
||||||
</div>
|
|
||||||
<span class="uppercase opacity-60 text-[#00ff41] text-[10px]">You</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Arrow to branch -->
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-8 h-[3px] bg-[#00ff41]"></div>
|
|
||||||
<i data-lucide="chevron-right" class="w-4 h-4 text-[#00ff41]"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Branch: Fallback + VPN -->
|
|
||||||
<div id="chainBranch" class="flex flex-col gap-3 py-1">
|
|
||||||
<!-- Fallback branch -->
|
|
||||||
<div id="chainFallbackRow" class="flex items-center gap-3 transition-all duration-300 hidden">
|
|
||||||
<div class="w-5 h-[3px] bg-[#00ff41]/50 rounded-full"></div>
|
|
||||||
<div id="chainFallbackBox"
|
|
||||||
class="relative w-16 h-12 border-2 border-[#00ff41]/30 flex items-center justify-center bg-[#0a0a0a] transition-all">
|
|
||||||
<i data-lucide="server" class="w-5 h-5 text-[#00ff41]/50"></i>
|
|
||||||
<div id="chainFallbackX" class="absolute inset-0 hidden items-center justify-center bg-black/60">
|
|
||||||
<i data-lucide="x" class="w-6 h-6 text-red-500"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span id="chainFallbackLabel"
|
|
||||||
class="uppercase text-[10px] opacity-60 text-[#00ff41]">Fallback</span>
|
|
||||||
<span id="chainFallbackLatency" class="text-xs text-[#00ff41]/50">--ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VPN branch -->
|
|
||||||
<div id="chainVPNRow" class="flex items-center gap-3 transition-all duration-300">
|
|
||||||
<div class="w-5 h-[3px] bg-[#00ff41]/50 rounded-full"></div>
|
|
||||||
<div id="chainVPNBox"
|
|
||||||
class="relative w-16 h-12 border-2 border-[#00ff41]/30 flex items-center justify-center bg-[#0a0a0a] transition-all">
|
|
||||||
<i data-lucide="shield" class="w-5 h-5 text-[#00ff41]/50"></i>
|
|
||||||
<div id="chainVPNX" class="absolute inset-0 hidden items-center justify-center bg-black/60">
|
|
||||||
<i data-lucide="x" class="w-6 h-6 text-red-500"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span id="chainVPNLabel" class="uppercase text-[10px] opacity-60 text-[#00ff41]">VPN</span>
|
|
||||||
<span id="chainVPNLatency" class="text-xs text-[#00ff41]/50">--ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Direct branch (when proxy disabled) -->
|
|
||||||
<div id="chainDirectRow" class="flex items-center gap-3 transition-all duration-300 hidden">
|
|
||||||
<div class="w-5 h-[3px] bg-yellow-500/50 rounded-full"></div>
|
|
||||||
<div id="chainDirectBox"
|
|
||||||
class="relative w-16 h-12 border-2 border-yellow-500/50 flex items-center justify-center bg-yellow-500/5 transition-all">
|
|
||||||
<i data-lucide="zap" class="w-5 h-5 text-yellow-500/70"></i>
|
|
||||||
</div>
|
|
||||||
<span class="uppercase text-[10px] opacity-60 text-yellow-500">DIRECT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Arrow from branch -->
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i data-lucide="chevron-right" class="w-4 h-4 text-[#00ff41]"></i>
|
|
||||||
<div class="w-8 h-[3px] bg-[#00ff41]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Internet -->
|
|
||||||
<div class="flex flex-col items-center justify-center gap-2">
|
|
||||||
<div
|
|
||||||
class="w-14 h-14 rounded-full border-2 border-blue-400 flex items-center justify-center bg-blue-400/10">
|
|
||||||
<i data-lucide="globe" class="w-6 h-6 text-blue-400"></i>
|
|
||||||
</div>
|
|
||||||
<span class="uppercase opacity-60 text-blue-400 text-[10px]">Internet</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chain Status -->
|
|
||||||
<div id="chainStatus"
|
|
||||||
class="mt-4 text-sm text-center py-2 px-4 bg-[#0a0a0a] border border-[#00ff41]/20 text-[#00ff41] uppercase">
|
|
||||||
No proxy configured
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!-- Server List as Cards -->
|
|
||||||
<div class="flex flex-col bg-[#0a0a0a]/50 border border-[#00ff41]/10 overflow-hidden">
|
|
||||||
<div class="px-4 py-2 border-b border-[#00ff41]/10 bg-black flex justify-between items-center shrink-0">
|
|
||||||
<span class="text-[11px] uppercase tracking-[0.2em] font-bold text-[#00ff41]">Servers</span>
|
|
||||||
<span id="serverCount" class="text-[10px] opacity-40 text-[#00ff41]">0 endpoints</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="serverListContainer" class="overflow-y-auto custom-scrollbar p-3 grid grid-cols-3 gap-2 max-h-[280px]">
|
|
||||||
<!-- Cards populated by JS -->
|
|
||||||
<div class="col-span-3 text-center py-6 text-[#00ff41]/30 text-xs uppercase">
|
|
||||||
No_Data // Awaiting_Sync
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<!-- Subscription Input -->
|
|
||||||
<div class="flex flex-col gap-2 p-4 bg-[#0a0a0a] border border-[#00ff41]/30 relative">
|
|
||||||
<label class="text-[11px] uppercase tracking-widest opacity-50 text-[#00ff41]">Subscription_URL</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="relative flex-grow">
|
|
||||||
<i data-lucide="link" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#00ff41]/40"></i>
|
|
||||||
<!-- Hidden full URL input -->
|
|
||||||
<input type="hidden" id="subUrlFull" />
|
|
||||||
<!-- Masked display input -->
|
|
||||||
<input type="text" id="subUrlInput" placeholder="https://provider.com/..."
|
|
||||||
class="w-full bg-black border border-[#00ff41]/20 py-2.5 pl-10 pr-10 text-sm tracking-wider focus:outline-none focus:border-[#00ff41] transition-all placeholder:text-[#00ff41]/20 text-[#00ff41]" />
|
|
||||||
<!-- Toggle visibility -->
|
|
||||||
<button id="toggleUrlVisibility" type="button"
|
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#00ff41]/40 hover:text-[#00ff41] transition-colors"
|
|
||||||
title="Show/Hide full URL">
|
|
||||||
<i data-lucide="eye-off" class="w-4 h-4" id="urlEyeIcon"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button id="fetchServersBtn"
|
|
||||||
class="flex items-center justify-center gap-2 bg-[#00ff41] text-black px-4 py-2.5 text-xs font-black uppercase tracking-widest hover:bg-white hover:shadow-[0_0_15px_rgba(0,255,65,0.4)] transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<i data-lucide="download" class="w-4 h-4" id="fetchIcon"></i>
|
|
||||||
<span id="fetchText">Sync</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!-- Master Proxy Toggle + Status -->
|
|
||||||
<div class="bg-black border-2 border-[#00ff41]/30 p-5 relative">
|
|
||||||
<div class="flex items-center justify-between gap-6">
|
|
||||||
<!-- Toggle -->
|
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<label class="big-toggle">
|
|
||||||
<input type="checkbox" id="masterProxyToggle" checked>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<div id="proxyModeLabel" class="text-xl font-black tracking-wider text-[#00ff41]">
|
|
||||||
VPN_MODE
|
|
||||||
</div>
|
|
||||||
<div id="proxyModeSubtitle" class="text-[11px] opacity-50 text-[#00ff41] uppercase">
|
|
||||||
Traffic routed via proxy
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Status -->
|
|
||||||
<div id="quickStatus" class="text-right hidden md:flex gap-6">
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] opacity-40 uppercase text-[#00ff41]">Uptime</div>
|
|
||||||
<div id="uptimeDisplay" class="text-lg font-bold text-[#00ff41]">00:00:00</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] opacity-40 uppercase text-[#00ff41]">Current_IP</div>
|
|
||||||
<div id="currentIpDisplay" class="text-lg font-bold text-white">---.---.---.---</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>VPN_CLIENT // SECURE_SHELL</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="min-h-screen flex flex-col relative selection:bg-[#00ff41] selection:text-black">
|
|
||||||
|
|
||||||
<!-- CRT Effects -->
|
|
||||||
<div class="matrix-bg fixed inset-0 z-0 pointer-events-none"></div>
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-40 pointer-events-none bg-[radial-gradient(circle_at_50%_50%,rgba(0,255,65,0.03)_0%,transparent_100%)]">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- include "components/header.html" -->
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<main
|
|
||||||
class="flex-grow max-w-[1400px] w-full mx-auto px-4 md:px-6 py-4 grid grid-cols-1 lg:grid-cols-12 gap-4 relative z-10">
|
|
||||||
|
|
||||||
<!-- Left Column: Main Controls -->
|
|
||||||
<div class="lg:col-span-7 flex flex-col gap-4">
|
|
||||||
|
|
||||||
<!-- include "components/switch.html" -->
|
|
||||||
<!-- include "components/proxy_chain.html" -->
|
|
||||||
<!-- include "components/fallback_config.html" -->
|
|
||||||
<!-- include "components/connection_info.html" -->
|
|
||||||
|
|
||||||
<!-- Placeholder for Map if needed later -->
|
|
||||||
<!-- include "components/map.html" -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Subscription & Servers -->
|
|
||||||
<div class="lg:col-span-5 flex flex-col gap-4">
|
|
||||||
|
|
||||||
<!-- include "components/subscription.html" -->
|
|
||||||
<!-- include "components/server_list.html" -->
|
|
||||||
<!-- include "components/logs.html" -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- include "components/footer.html" -->
|
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap");
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-neon: #00ff41;
|
|
||||||
--color-bg: #050505;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "JetBrains Mono", monospace;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-neon);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background-color: var(--color-neon);
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar */
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 255, 65, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(0, 255, 65, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-bg {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
|
|
||||||
linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(255, 0, 0, 0.06),
|
|
||||||
rgba(0, 255, 0, 0.02),
|
|
||||||
rgba(0, 0, 255, 0.06)
|
|
||||||
);
|
|
||||||
background-size:
|
|
||||||
100% 2px,
|
|
||||||
3px 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Big Toggle Switch */
|
|
||||||
.big-toggle {
|
|
||||||
position: relative;
|
|
||||||
width: 80px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-toggle input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-toggle .slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
inset: 0;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
border: 2px solid rgba(0, 255, 65, 0.3);
|
|
||||||
border-radius: 40px;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-toggle .slider:before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 28px;
|
|
||||||
width: 28px;
|
|
||||||
left: 4px;
|
|
||||||
bottom: 4px;
|
|
||||||
background-color: rgba(0, 255, 65, 0.4);
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-toggle input:checked + .slider {
|
|
||||||
background-color: rgba(0, 255, 65, 0.2);
|
|
||||||
border-color: #00ff41;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 255, 65, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-toggle input:checked + .slider:before {
|
|
||||||
transform: translateX(40px);
|
|
||||||
background-color: #00ff41;
|
|
||||||
box-shadow: 0 0 10px #00ff41;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Server Card */
|
|
||||||
.server-card {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 255, 65, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card.active {
|
|
||||||
border-color: #00ff41 !important;
|
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 65, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blink-1 {
|
|
||||||
animation: blink 1s infinite;
|
|
||||||
}
|
|
||||||
.blink-2 {
|
|
||||||
animation: blink 1s infinite 0.2s;
|
|
||||||
}
|
|
||||||
.blink-3 {
|
|
||||||
animation: blink 1s infinite 0.4s;
|
|
||||||
}
|
|
||||||
@keyframes blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,733 +0,0 @@
|
|||||||
// --- Icons Initialization ---
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const state = {
|
|
||||||
nodes: [],
|
|
||||||
logs: [],
|
|
||||||
activeNode: null,
|
|
||||||
isFetching: false,
|
|
||||||
isConnecting: false,
|
|
||||||
subscriptionUrl: '',
|
|
||||||
sessionId: Math.random().toString(16).substr(2, 8).toUpperCase(),
|
|
||||||
userInfo: null,
|
|
||||||
proxyEnabled: true,
|
|
||||||
urlVisible: false,
|
|
||||||
serverStartTime: 0,
|
|
||||||
uptimeInterval: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- DOM Elements ---
|
|
||||||
const els = {
|
|
||||||
subUrlInput: document.getElementById('subUrlInput'),
|
|
||||||
subUrlFull: document.getElementById('subUrlFull'),
|
|
||||||
toggleUrlVisibility: document.getElementById('toggleUrlVisibility'),
|
|
||||||
urlEyeIcon: document.getElementById('urlEyeIcon'),
|
|
||||||
fetchServersBtn: document.getElementById('fetchServersBtn'),
|
|
||||||
fetchIcon: document.getElementById('fetchIcon'),
|
|
||||||
fetchText: document.getElementById('fetchText'),
|
|
||||||
serverListContainer: document.getElementById('serverListContainer'),
|
|
||||||
serverCount: document.getElementById('serverCount'),
|
|
||||||
logsContainer: document.getElementById('logsContainer'),
|
|
||||||
clearLogs: document.getElementById('clearLogs'),
|
|
||||||
headerStatus: document.getElementById('headerStatus'),
|
|
||||||
sessionId: document.getElementById('sessionId'),
|
|
||||||
trafficValue: document.getElementById('trafficValue'),
|
|
||||||
// Master toggle
|
|
||||||
masterProxyToggle: document.getElementById('masterProxyToggle'),
|
|
||||||
proxyModeLabel: document.getElementById('proxyModeLabel'),
|
|
||||||
proxyModeSubtitle: document.getElementById('proxyModeSubtitle'),
|
|
||||||
currentIpDisplay: document.getElementById('currentIpDisplay'),
|
|
||||||
// Fallback Proxy elements
|
|
||||||
fallbackToggle: document.getElementById('fallbackToggle'),
|
|
||||||
fallbackToggleLabel: document.getElementById('fallbackToggleLabel'),
|
|
||||||
fallbackHost: document.getElementById('fallbackHost'),
|
|
||||||
fallbackPort: document.getElementById('fallbackPort'),
|
|
||||||
saveFallbackBtn: document.getElementById('saveFallbackBtn'),
|
|
||||||
fallbackStatus: document.getElementById('fallbackStatus'),
|
|
||||||
// Proxy Chain visualization
|
|
||||||
chainFallbackRow: document.getElementById('chainFallbackRow'),
|
|
||||||
chainFallbackBox: document.getElementById('chainFallbackBox'),
|
|
||||||
chainFallbackLabel: document.getElementById('chainFallbackLabel'),
|
|
||||||
chainFallbackLatency: document.getElementById('chainFallbackLatency'),
|
|
||||||
chainFallbackX: document.getElementById('chainFallbackX'),
|
|
||||||
chainVPNRow: document.getElementById('chainVPNRow'),
|
|
||||||
chainVPNBox: document.getElementById('chainVPNBox'),
|
|
||||||
chainVPNLabel: document.getElementById('chainVPNLabel'),
|
|
||||||
chainVPNLatency: document.getElementById('chainVPNLatency'),
|
|
||||||
chainVPNX: document.getElementById('chainVPNX'),
|
|
||||||
chainDirectRow: document.getElementById('chainDirectRow'),
|
|
||||||
chainStatus: document.getElementById('chainStatus'),
|
|
||||||
// Connection settings
|
|
||||||
httpProxyUrl: document.getElementById('httpProxyUrl'),
|
|
||||||
socks5ProxyUrl: document.getElementById('socks5ProxyUrl'),
|
|
||||||
// Uptime
|
|
||||||
uptimeDisplay: document.getElementById('uptimeDisplay')
|
|
||||||
};
|
|
||||||
|
|
||||||
els.sessionId.textContent = state.sessionId;
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
function formatBytes(bytes, decimals = 1) {
|
|
||||||
if (!+bytes) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskUrl(url) {
|
|
||||||
if (!url) return '';
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return `${parsed.hostname}/...`;
|
|
||||||
} catch {
|
|
||||||
return url.length > 30 ? url.substring(0, 30) + '...' : url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUptime(seconds) {
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startUptimeTimer() {
|
|
||||||
if (state.uptimeInterval) clearInterval(state.uptimeInterval);
|
|
||||||
if (!state.serverStartTime || state.serverStartTime <= 0) {
|
|
||||||
els.uptimeDisplay.textContent = '00:00:00';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust local offset if needed, but simple diff is usually enough
|
|
||||||
const update = () => {
|
|
||||||
const now = Date.now() / 1000;
|
|
||||||
const elapsed = Math.floor(now - state.serverStartTime);
|
|
||||||
if (elapsed >= 0) {
|
|
||||||
els.uptimeDisplay.textContent = formatUptime(elapsed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
update();
|
|
||||||
state.uptimeInterval = setInterval(update, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopUptimeTimer() {
|
|
||||||
if (state.uptimeInterval) {
|
|
||||||
clearInterval(state.uptimeInterval);
|
|
||||||
state.uptimeInterval = null;
|
|
||||||
}
|
|
||||||
els.uptimeDisplay.textContent = '00:00:00';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- URL Visibility Toggle ---
|
|
||||||
els.toggleUrlVisibility.addEventListener('click', () => {
|
|
||||||
state.urlVisible = !state.urlVisible;
|
|
||||||
updateUrlDisplay();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateUrlDisplay() {
|
|
||||||
const fullUrl = els.subUrlFull.value || state.subscriptionUrl;
|
|
||||||
if (state.urlVisible) {
|
|
||||||
els.subUrlInput.value = fullUrl;
|
|
||||||
els.urlEyeIcon.setAttribute('data-lucide', 'eye');
|
|
||||||
} else {
|
|
||||||
els.subUrlInput.value = maskUrl(fullUrl);
|
|
||||||
els.urlEyeIcon.setAttribute('data-lucide', 'eye-off');
|
|
||||||
}
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
els.subUrlInput.addEventListener('input', () => {
|
|
||||||
// When user types, store full URL
|
|
||||||
els.subUrlFull.value = els.subUrlInput.value;
|
|
||||||
state.subscriptionUrl = els.subUrlInput.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
els.subUrlInput.addEventListener('blur', () => {
|
|
||||||
// On blur, mask if not visible
|
|
||||||
if (!state.urlVisible && els.subUrlFull.value) {
|
|
||||||
els.subUrlInput.value = maskUrl(els.subUrlFull.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
els.subUrlInput.addEventListener('focus', () => {
|
|
||||||
// On focus, show full for editing
|
|
||||||
if (els.subUrlFull.value) {
|
|
||||||
els.subUrlInput.value = els.subUrlFull.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Logger ---
|
|
||||||
function addLog(msg, type = 'info') {
|
|
||||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
|
||||||
|
|
||||||
const logEl = document.createElement('div');
|
|
||||||
logEl.className = 'flex gap-2 items-start leading-tight animate-in fade-in slide-in-from-left-1 duration-300';
|
|
||||||
|
|
||||||
let colorClass = 'text-[#00ff41]/70';
|
|
||||||
let prefix = '>>';
|
|
||||||
if (type === 'success') { colorClass = 'text-blue-400'; prefix = 'OK.'; }
|
|
||||||
if (type === 'warning') { colorClass = 'text-yellow-500'; prefix = '!!'; }
|
|
||||||
if (type === 'error') { colorClass = 'text-red-500'; prefix = 'ERR'; }
|
|
||||||
|
|
||||||
logEl.innerHTML = `
|
|
||||||
<span class="opacity-20 shrink-0 tracking-tighter text-[#00ff41]">[${time}]</span>
|
|
||||||
<span class="${colorClass}">${prefix} ${msg}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
els.logsContainer.insertBefore(logEl, els.logsContainer.lastElementChild);
|
|
||||||
els.logsContainer.scrollTop = els.logsContainer.scrollHeight;
|
|
||||||
|
|
||||||
// Limit logs
|
|
||||||
while (els.logsContainer.children.length > 50) {
|
|
||||||
els.logsContainer.removeChild(els.logsContainer.firstElementChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global copy function for proxy URLs
|
|
||||||
function copyToClipboard(inputId, btn) {
|
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
navigator.clipboard.writeText(input.value).then(() => {
|
|
||||||
const originalText = btn.textContent;
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
btn.classList.add('text-blue-400');
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.textContent = originalText;
|
|
||||||
btn.classList.remove('text-blue-400');
|
|
||||||
}, 1500);
|
|
||||||
addLog(`COPIED: ${input.value}`, 'success');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
els.clearLogs.addEventListener('click', () => {
|
|
||||||
const lastChild = els.logsContainer.lastElementChild;
|
|
||||||
els.logsContainer.innerHTML = '';
|
|
||||||
els.logsContainer.appendChild(lastChild);
|
|
||||||
addLog('LOGS_CLEARED', 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- UI Rendering ---
|
|
||||||
function renderNodes() {
|
|
||||||
els.serverListContainer.innerHTML = '';
|
|
||||||
|
|
||||||
if (state.nodes.length === 0) {
|
|
||||||
els.serverListContainer.innerHTML = `
|
|
||||||
<div class="col-span-3 text-center py-6 text-[#00ff41]/30 text-xs uppercase">
|
|
||||||
No_Data // Awaiting_Sync
|
|
||||||
</div>`;
|
|
||||||
els.serverCount.textContent = '0 endpoints';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
els.serverCount.textContent = `${state.nodes.length} endpoints`;
|
|
||||||
|
|
||||||
state.nodes.forEach((node, index) => {
|
|
||||||
const isActive = state.activeNode && state.activeNode.tag === node.tag;
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = `server-card cursor-pointer p-2 bg-black border border-[#00ff41]/20 hover:border-[#00ff41]/50 ${isActive ? 'active' : ''}`;
|
|
||||||
card.onclick = () => handleConnect(node);
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="flex items-center gap-1.5 mb-1">
|
|
||||||
<div class="w-1.5 h-1.5 rounded-full ${isActive ? 'bg-[#00ff41] animate-pulse' : 'bg-[#00ff41]/30'}"></div>
|
|
||||||
<span class="text-[10px] font-bold text-white uppercase truncate">${node.tag}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-[9px] opacity-40 text-[#00ff41] truncate">${node.type}</div>
|
|
||||||
<div id="ping-${index}" class="text-[10px] font-mono text-[#00ff41]/70 mt-1">--</div>
|
|
||||||
`;
|
|
||||||
els.serverListContainer.appendChild(card);
|
|
||||||
});
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMasterToggleUI() {
|
|
||||||
if (state.proxyEnabled) {
|
|
||||||
els.proxyModeLabel.textContent = 'VPN_MODE';
|
|
||||||
els.proxyModeLabel.classList.remove('text-yellow-500');
|
|
||||||
els.proxyModeLabel.classList.add('text-[#00ff41]');
|
|
||||||
els.proxyModeSubtitle.textContent = 'Traffic routed via proxy';
|
|
||||||
els.headerStatus.textContent = state.activeNode ? 'TUNNEL_UP' : 'STANDBY';
|
|
||||||
|
|
||||||
// Show VPN row, hide direct
|
|
||||||
els.chainVPNRow.classList.remove('hidden');
|
|
||||||
els.chainDirectRow.classList.add('hidden');
|
|
||||||
|
|
||||||
// Start uptime if connected
|
|
||||||
if (state.activeNode && !state.uptimeInterval) {
|
|
||||||
startUptimeTimer();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
els.proxyModeLabel.textContent = 'DIRECT_MODE';
|
|
||||||
els.proxyModeLabel.classList.remove('text-[#00ff41]');
|
|
||||||
els.proxyModeLabel.classList.add('text-yellow-500');
|
|
||||||
els.proxyModeSubtitle.textContent = 'Bypass proxy — direct connection';
|
|
||||||
els.headerStatus.textContent = 'DIRECT';
|
|
||||||
|
|
||||||
// Hide VPN/Fallback, show direct
|
|
||||||
els.chainVPNRow.classList.add('hidden');
|
|
||||||
els.chainFallbackRow.classList.add('hidden');
|
|
||||||
els.chainDirectRow.classList.remove('hidden');
|
|
||||||
|
|
||||||
els.chainStatus.innerHTML = `<span class="text-yellow-400">●</span> Direct connection active`;
|
|
||||||
|
|
||||||
// Stop uptime timer
|
|
||||||
stopUptimeTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTrafficUI(info) {
|
|
||||||
if (!info) return;
|
|
||||||
const used = formatBytes((info.download || 0) + (info.upload || 0));
|
|
||||||
const total = info.total ? formatBytes(info.total) : '∞';
|
|
||||||
els.trafficValue.textContent = `${used} / ${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkServerLatencies(nodes) {
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const node = nodes[i];
|
|
||||||
const pingEl = document.getElementById(`ping-${i}`);
|
|
||||||
if (pingEl) pingEl.textContent = '...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/ping-target', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ server: node.server, port: node.server_port || 443 })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (pingEl) {
|
|
||||||
if (data.latency && data.latency !== -1) {
|
|
||||||
pingEl.textContent = data.latency + 'ms';
|
|
||||||
if (data.latency > 300) pingEl.style.color = 'rgb(239, 68, 68)';
|
|
||||||
else if (data.latency < 100) pingEl.style.color = '#00ff41';
|
|
||||||
else pingEl.style.color = 'rgb(234, 179, 8)';
|
|
||||||
} else {
|
|
||||||
pingEl.textContent = 'Timeout';
|
|
||||||
pingEl.style.color = 'rgb(239, 68, 68)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (pingEl) pingEl.textContent = 'Err';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkConnectionSpeed(fullTest = false) {
|
|
||||||
els.currentIpDisplay.textContent = '...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/test-connection?speed=${fullTest}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
els.currentIpDisplay.textContent = 'ERROR';
|
|
||||||
} else {
|
|
||||||
els.currentIpDisplay.textContent = data.ip || '---.---.---.---';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
els.currentIpDisplay.textContent = 'NET_ERR';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Master Proxy Toggle ---
|
|
||||||
els.masterProxyToggle.addEventListener('change', async () => {
|
|
||||||
state.proxyEnabled = els.masterProxyToggle.checked;
|
|
||||||
updateMasterToggleUI();
|
|
||||||
|
|
||||||
addLog(state.proxyEnabled ? 'PROXY_ENABLED' : 'PROXY_DISABLED_DIRECT_MODE', state.proxyEnabled ? 'success' : 'warning');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/proxy-enabled', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: state.proxyEnabled })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
addLog('CONFIG_APPLIED', 'success');
|
|
||||||
|
|
||||||
// Force fetch new status to get correct fallback/uptime state
|
|
||||||
setTimeout(async () => {
|
|
||||||
await fetchStatus();
|
|
||||||
checkConnectionSpeed(false);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
addLog('TOGGLE_FAILED: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProxyChain();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
async function handleFetchNodes() {
|
|
||||||
// Get the actual URL (from hidden field or input)
|
|
||||||
let url = els.subUrlFull.value || els.subUrlInput.value;
|
|
||||||
url = url.trim();
|
|
||||||
|
|
||||||
if (!url || url.endsWith('/...')) {
|
|
||||||
addLog('ERROR_MISSING_URL', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.subscriptionUrl = url;
|
|
||||||
els.subUrlFull.value = url;
|
|
||||||
|
|
||||||
state.isFetching = true;
|
|
||||||
els.fetchIcon.classList.add('hidden');
|
|
||||||
els.fetchText.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 animate-spin"></i>';
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
addLog(`FETCHING: ${maskUrl(url)}`, 'info');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/fetch-subscription', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success && data.servers) {
|
|
||||||
state.nodes = data.servers;
|
|
||||||
state.config = data.config;
|
|
||||||
state.userInfo = data.userInfo;
|
|
||||||
|
|
||||||
renderNodes();
|
|
||||||
updateTrafficUI(state.userInfo);
|
|
||||||
updateUrlDisplay();
|
|
||||||
addLog(`SYNC_OK: ${state.nodes.length} endpoints`, 'success');
|
|
||||||
|
|
||||||
addLog('CHECKING_LATENCY...', 'info');
|
|
||||||
checkServerLatencies(state.nodes);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'Unknown Error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
addLog(`SYNC_FAILED: ${e.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
state.isFetching = false;
|
|
||||||
els.fetchIcon.classList.remove('hidden');
|
|
||||||
els.fetchText.textContent = 'Sync';
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConnect(node) {
|
|
||||||
if (!state.proxyEnabled) {
|
|
||||||
addLog('ENABLE_PROXY_FIRST', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.activeNode && state.activeNode.tag === node.tag && !state.isConnecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.activeNode = node;
|
|
||||||
state.isConnecting = true;
|
|
||||||
// Block UI
|
|
||||||
els.serverListContainer.classList.add('pointer-events-none', 'opacity-50');
|
|
||||||
renderNodes();
|
|
||||||
|
|
||||||
addLog(`CONNECTING: ${node.tag}`, 'warning');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/apply-subscription', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
config: state.config,
|
|
||||||
selectedServer: node.tag,
|
|
||||||
subUrl: state.subscriptionUrl,
|
|
||||||
userInfo: state.userInfo
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setTimeout(async () => {
|
|
||||||
state.isConnecting = false;
|
|
||||||
els.serverListContainer.classList.remove('pointer-events-none', 'opacity-50');
|
|
||||||
els.headerStatus.textContent = 'TUNNEL_UP';
|
|
||||||
renderNodes();
|
|
||||||
addLog(`CONNECTED: ${node.tag}`, 'success');
|
|
||||||
|
|
||||||
// Wait for next status update to sync start time properly
|
|
||||||
await fetchStatus();
|
|
||||||
|
|
||||||
// Fallback: start timer optimistically if status didn't catch it yet
|
|
||||||
if (!state.uptimeInterval) {
|
|
||||||
state.serverStartTime = Date.now() / 1000;
|
|
||||||
startUptimeTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
checkConnectionSpeed(false);
|
|
||||||
updateProxyChain();
|
|
||||||
|
|
||||||
}, 800);
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
state.isConnecting = false;
|
|
||||||
els.serverListContainer.classList.remove('pointer-events-none', 'opacity-50');
|
|
||||||
state.activeNode = null;
|
|
||||||
renderNodes();
|
|
||||||
addLog(`CONNECT_FAILED: ${e.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/status');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.proxyPort) {
|
|
||||||
const host = window.location.hostname;
|
|
||||||
els.httpProxyUrl.value = `http://${host}:${data.proxyPort}`;
|
|
||||||
els.socks5ProxyUrl.value = `socks5://${host}:${data.proxyPort}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.proxyEnabled !== undefined) {
|
|
||||||
state.proxyEnabled = data.proxyEnabled;
|
|
||||||
els.masterProxyToggle.checked = state.proxyEnabled;
|
|
||||||
updateMasterToggleUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.startTime) {
|
|
||||||
state.serverStartTime = data.startTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.active && data.tag && state.proxyEnabled) {
|
|
||||||
const currentTag = state.activeNode ? state.activeNode.tag : null;
|
|
||||||
|
|
||||||
if (currentTag !== data.tag) {
|
|
||||||
const fullNode = state.nodes.find(n => n.tag === data.tag);
|
|
||||||
|
|
||||||
if (fullNode) {
|
|
||||||
state.activeNode = fullNode;
|
|
||||||
} else {
|
|
||||||
state.activeNode = { tag: data.tag, server: data.server, port: '?', type: 'UNKNOWN' };
|
|
||||||
}
|
|
||||||
|
|
||||||
renderNodes();
|
|
||||||
checkConnectionSpeed(false);
|
|
||||||
|
|
||||||
// Restart timer with new server start time
|
|
||||||
startUptimeTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure timer is running if active
|
|
||||||
if (!state.uptimeInterval) startUptimeTimer();
|
|
||||||
} else if (!data.active && state.proxyEnabled) {
|
|
||||||
// Proxy enabled but backend says not active/configured
|
|
||||||
state.activeNode = null;
|
|
||||||
renderNodes();
|
|
||||||
stopUptimeTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always update chain visualization
|
|
||||||
updateProxyChain();
|
|
||||||
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSaved() {
|
|
||||||
try {
|
|
||||||
addLog('SYSTEM_BOOT...', 'info');
|
|
||||||
const res = await fetch('/subscription');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.saved && data.url) {
|
|
||||||
state.subscriptionUrl = data.url;
|
|
||||||
els.subUrlFull.value = data.url;
|
|
||||||
els.subUrlInput.value = maskUrl(data.url);
|
|
||||||
state.userInfo = data.userInfo;
|
|
||||||
updateTrafficUI(state.userInfo);
|
|
||||||
|
|
||||||
await handleFetchNodes();
|
|
||||||
} else {
|
|
||||||
addLog('NO_SAVED_CONFIG', 'warning');
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchStatus();
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
addLog('BOOT_ERROR', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event Listeners ---
|
|
||||||
els.fetchServersBtn.addEventListener('click', handleFetchNodes);
|
|
||||||
|
|
||||||
// --- Fallback Proxy Functions ---
|
|
||||||
async function loadFallbackConfig() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/fallback-config');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
els.fallbackToggle.checked = data.enabled || false;
|
|
||||||
els.fallbackHost.value = data.host || '192.168.50.111';
|
|
||||||
els.fallbackPort.value = data.port || 8080;
|
|
||||||
|
|
||||||
updateFallbackUI(data.enabled || false);
|
|
||||||
} catch (e) {
|
|
||||||
addLog('FALLBACK_LOAD_FAILED', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFallbackUI(enabled) {
|
|
||||||
els.fallbackToggleLabel.textContent = enabled ? 'ON' : 'OFF';
|
|
||||||
els.fallbackToggleLabel.classList.toggle('opacity-100', enabled);
|
|
||||||
els.fallbackToggleLabel.classList.toggle('opacity-50', !enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveFallbackConfig() {
|
|
||||||
const enabled = els.fallbackToggle.checked;
|
|
||||||
const host = els.fallbackHost.value.trim();
|
|
||||||
const port = parseInt(els.fallbackPort.value) || 8080;
|
|
||||||
|
|
||||||
if (enabled && !host) {
|
|
||||||
addLog('FALLBACK_HOST_REQUIRED', 'error');
|
|
||||||
showFallbackStatus('Host required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/fallback-config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled, host, port })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
addLog(enabled ? 'FALLBACK_ON' : 'FALLBACK_OFF', 'success');
|
|
||||||
showFallbackStatus('Applied!', 'success');
|
|
||||||
updateFallbackUI(enabled);
|
|
||||||
updateProxyChain();
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
addLog(`FALLBACK_FAILED: ${e.message}`, 'error');
|
|
||||||
showFallbackStatus('Failed', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFallbackStatus(msg, type = 'info') {
|
|
||||||
els.fallbackStatus.textContent = msg;
|
|
||||||
els.fallbackStatus.classList.remove('hidden', 'text-[#00ff41]/50', 'text-red-500', 'text-blue-400');
|
|
||||||
|
|
||||||
if (type === 'success') els.fallbackStatus.classList.add('text-blue-400');
|
|
||||||
else if (type === 'error') els.fallbackStatus.classList.add('text-red-500');
|
|
||||||
else els.fallbackStatus.classList.add('text-[#00ff41]/50');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
els.fallbackStatus.classList.add('hidden');
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Proxy Chain Visualization ---
|
|
||||||
async function updateProxyChain() {
|
|
||||||
// Always update UI based on current state first for immediate feedback
|
|
||||||
if (!state.proxyEnabled) {
|
|
||||||
// Direct mode is handled by updateMasterToggleUI generally,
|
|
||||||
// but we ensure clean slate here too if needed
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If we are in VPN mode but no node selected (Standby)
|
|
||||||
if (!state.activeNode) {
|
|
||||||
els.chainVPNLabel.textContent = 'VPN (Standby)';
|
|
||||||
els.chainVPNLatency.textContent = '--ms';
|
|
||||||
els.chainVPNBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20');
|
|
||||||
els.chainVPNBox.classList.add('border-[#00ff41]/30', 'border-dashed');
|
|
||||||
els.chainStatus.innerHTML = '<span class="text-orange-400">●</span> VPN Standby - Select Server';
|
|
||||||
// Show X to indicate no connection through VPN yet
|
|
||||||
els.chainVPNX.classList.remove('hidden');
|
|
||||||
els.chainVPNX.classList.add('flex');
|
|
||||||
} else {
|
|
||||||
els.chainVPNBox.classList.remove('border-dashed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('/active-proxy');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
|
|
||||||
// Reset all states
|
|
||||||
els.chainFallbackBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20');
|
|
||||||
els.chainFallbackBox.classList.add('border-[#00ff41]/30');
|
|
||||||
els.chainVPNBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20');
|
|
||||||
els.chainVPNBox.classList.add('border-[#00ff41]/30');
|
|
||||||
els.chainFallbackX.classList.add('hidden');
|
|
||||||
els.chainFallbackX.classList.remove('flex');
|
|
||||||
els.chainVPNX.classList.add('hidden');
|
|
||||||
els.chainVPNX.classList.remove('flex');
|
|
||||||
|
|
||||||
if (!data.configured) {
|
|
||||||
els.chainStatus.innerHTML = '<span class="text-orange-400">●</span> VPN Standby';
|
|
||||||
els.chainFallbackRow.classList.add('hidden');
|
|
||||||
els.chainVPNLabel.textContent = 'Select Server';
|
|
||||||
// Visual cue for disconnected VPN
|
|
||||||
els.chainVPNX.classList.remove('hidden');
|
|
||||||
els.chainVPNX.classList.add('flex');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update VPN label
|
|
||||||
els.chainVPNLabel.textContent = data.vpnTag || 'VPN';
|
|
||||||
els.chainVPNLatency.textContent = data.vpnLatency ? `${data.vpnLatency}ms` : '--ms';
|
|
||||||
|
|
||||||
if (data.fallbackEnabled) {
|
|
||||||
els.chainFallbackRow.classList.remove('hidden');
|
|
||||||
els.chainFallbackLabel.textContent = data.fallbackHost || 'Fallback';
|
|
||||||
els.chainFallbackLatency.textContent = data.fallbackLatency ? `${data.fallbackLatency}ms` : '--ms';
|
|
||||||
|
|
||||||
if (data.fallbackReachable) {
|
|
||||||
els.chainFallbackBox.classList.remove('border-[#00ff41]/30');
|
|
||||||
els.chainFallbackBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20');
|
|
||||||
els.chainStatus.innerHTML = `<span class="text-[#00ff41]">●</span> Fallback active (${data.fallbackLatency}ms)`;
|
|
||||||
} else {
|
|
||||||
els.chainFallbackX.classList.remove('hidden');
|
|
||||||
els.chainFallbackX.classList.add('flex');
|
|
||||||
els.chainVPNBox.classList.remove('border-[#00ff41]/30');
|
|
||||||
els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20');
|
|
||||||
els.chainStatus.innerHTML = `<span class="text-yellow-400">●</span> VPN active (fallback down)`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
els.chainFallbackRow.classList.add('hidden');
|
|
||||||
els.chainVPNBox.classList.remove('border-[#00ff41]/30');
|
|
||||||
els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20');
|
|
||||||
els.chainStatus.innerHTML = `<span class="text-[#00ff41]">●</span> VPN direct`;
|
|
||||||
}
|
|
||||||
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
els.chainStatus.textContent = 'Failed to load';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback Event Listeners
|
|
||||||
els.saveFallbackBtn.addEventListener('click', saveFallbackConfig);
|
|
||||||
els.fallbackToggle.addEventListener('change', saveFallbackConfig);
|
|
||||||
|
|
||||||
// --- Init ---
|
|
||||||
addLog('TERMINAL_READY', 'info');
|
|
||||||
loadSaved();
|
|
||||||
loadFallbackConfig();
|
|
||||||
updateProxyChain();
|
|
||||||
|
|
||||||
// Periodically update proxy chain
|
|
||||||
setInterval(updateProxyChain, 10000);
|
|
||||||
Reference in New Issue
Block a user