From 2d61830d08a90d87c54ac019c1be11535ae5b3b7 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 23 Dec 2025 17:51:50 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=80=D0=B5=D0=BE=D1=80=D0=B3?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Созданы директории: docker/, scripts/, config/ - Перемещены файлы Docker (Dockerfile, entrypoint.sh) в docker/ - Перемещены утилитарные скрипты в scripts/ - Шаблон конфигурации перенесен в config/ - Веб-сервер перемещен в web/ и переименован в server.py - Обновлены пути в docker-compose.yml, Dockerfile и entrypoint.sh --- .gitignore | 1 + README.md | 330 +++++++++++++++++++++++- config/client.template.json | 52 ++++ docker-compose.yml | 23 ++ docker/Dockerfile.singbox | 19 ++ docker/entrypoint.sh | 106 ++++++++ scripts/gen-client-from-url.sh | 149 +++++++++++ scripts/menu.sh | 101 ++++++++ web/index.html | 448 +++++++++++++++++++++++++++++++++ web/server.py | 167 ++++++++++++ 10 files changed, 1395 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 config/client.template.json create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.singbox create mode 100644 docker/entrypoint.sh create mode 100755 scripts/gen-client-from-url.sh create mode 100644 scripts/menu.sh create mode 100644 web/index.html create mode 100644 web/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1269488 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data diff --git a/README.md b/README.md index 303c1c4..7c415ec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,330 @@ -# vpn-proxy +# 🌐 VPN Proxy — Домашний VPN в одной программе +> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях. + +--- + +## 📖 Что это такое? + +Это набор инструментов, который позволяет: + +1. **Запустить VPN-прокси** на вашем компьютере +2. **Управлять через веб-интерфейс** — открываете в браузере, вставляете ссылку, готово! +3. **Подключить браузер или приложения** (например, VS Code) через этот прокси +4. **Автоматически обновлять конфигурацию** с вашего VPN-провайдера + +### 🎯 Для кого это? + +- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам +- Разработчики, которые хотят направить трафик VS Code или других программ через VPN +- Люди, которые получили "ссылку подписки" от VPN-провайдера + +--- + +## 🧩 Как это работает? + +``` +┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет +│ или VS Code │ │ (порт 8082) │ │ (в другой стране)│ +└─────────────────┘ └──────────────────┘ └──────────────────┘ + ▲ + │ + ┌──────────────┐ + │ Веб-интерфейс│ + │ (порт 3456) │ + └──────────────┘ +``` + +--- + +## 📦 Что внутри? + +| Файл | Описание простыми словами | +| ------------------------ | ------------------------------------------------------------------------ | +| `web_server.py` | Веб-интерфейс для управления через браузер | +| `web/index.html` | Страница с красивым интерфейсом | +| `client.template.json` | Шаблон настроек — как "бланк анкеты", который заполняется вашими данными | +| `gen-client-from-url.sh` | Скрипт, который берёт вашу VPN-ссылку и заполняет "анкету" | +| `menu.sh` | Интерактивное меню для выбора сервера из списка (консольная версия) | +| `entrypoint.sh` | Главный скрипт запуска с функцией авто-обновления | +| `Dockerfile.singbox` | Инструкция для создания изолированного VPN-приложения (контейнера) | +| `docker-compose.yml` | Файл для удобного запуска одной командой | + +--- + +## 🚀 Быстрый старт + +### Что вам понадобится + +1. **VPN-ссылка** — получите её от вашего VPN-провайдера. Бывает двух видов: + - **Прямая ссылка**: начинается с `vless://...` + - **Ссылка подписки**: обычный URL (начинается с `https://...`), который содержит список серверов + +2. **Docker** — программа для запуска изолированных приложений + - [Скачать Docker Desktop](https://www.docker.com/products/docker-desktop/) (бесплатно) + - После установки убедитесь, что Docker запущен (иконка 🐳 в трее) + +--- + +## 📥 Первый запуск (установка с нуля) + +Откройте терминал (Терминал на Mac, PowerShell на Windows) и выполните: + +```bash +# 1. Перейдите в папку с проектом +cd путь/к/папке/vpn-proxy + +# 2. Соберите контейнер (это нужно сделать только один раз) +docker compose build + +# 3. Запустите контейнер +docker compose up -d +``` + +> 💡 **Что происходит:** +> +> - `docker compose build` — создаёт образ контейнера со всеми необходимыми программами +> - `docker compose up -d` — запускает контейнер в фоновом режиме + +### После запуска + +1. **Откройте веб-интерфейс**: http://localhost:3456 +2. **Вставьте вашу VPN-ссылку** (vless:// или https://) +3. **Нажмите "Применить"** +4. Готово! Прокси работает на порту **8082** + +--- + +## 🔄 Обновление (если уже была установлена старая версия) + +Если вы обновили код из репозитория, нужно пересобрать контейнер: + +```bash +# 1. Перейдите в папку с проектом +cd путь/к/папке/vpn-proxy + +# 2. Остановите текущий контейнер +docker compose down + +# 3. Пересоберите контейнер с новыми изменениями +docker compose build --no-cache + +# 4. Запустите заново +docker compose up -d +``` + +> 💡 **Примечание:** после пересборки нужно снова применить VPN-ссылку через веб-интерфейс http://localhost:3456 + +--- + +## 🌐 Порты + +| Порт | Для чего | URL | +| ------ | ------------------------------------------------- | ----------------------- | +| `3456` | **Веб-интерфейс** — управление через браузер | http://localhost:3456 | +| `8082` | **Прокси** — сюда подключаются браузер/приложения | `http://127.0.0.1:8082` | +| `9090` | Внутренний порт управления (обычно не нужен) | — | + +--- + +## ✅ Проверка работы + +### Через веб-интерфейс + +Откройте http://localhost:3456 — если видите зелёный индикатор "Активен", значит прокси работает. + +### Через терминал + +```bash +# Через прокси — должен показать IP VPN-сервера +curl -x http://127.0.0.1:8082 https://ipinfo.io/json +``` + +Если показывает IP другой страны — VPN работает! 🎉 + +--- + +## ⚙️ Настройка приложений + +### Для VS Code + +Откройте настройки (Cmd+, на Mac или Ctrl+, на Windows), найдите "proxy" и добавьте: + +``` +http.proxy: http://127.0.0.1:8082 +``` + +Или добавьте в `settings.json`: + +```json +{ + "http.proxy": "http://127.0.0.1:8082", + "http.proxyStrictSSL": true +} +``` + +### Для браузера + +В настройках прокси вашего браузера укажите: + +- **Тип**: HTTP или SOCKS5 +- **Адрес**: `127.0.0.1` +- **Порт**: `8082` + +--- + +## 🔄 Смена сервера + +### Через веб-интерфейс (рекомендуется) + +1. Откройте http://localhost:3456 +2. Вставьте новую ссылку +3. Нажмите "Применить" + +### Через консоль (если нужен выбор из списка) + +Если у вас ссылка подписки с несколькими серверами: + +```bash +docker exec -it sing-proxy ./menu.sh "https://ваша-ссылка-подписки..." +``` + +Появится список серверов для выбора. + +--- + +## 📋 Управление контейнером + +| Действие | Команда | +| ----------------------- | ---------------------------------- | +| Посмотреть статус | `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` | + +--- + +## ❓ Часто задаваемые вопросы + +### Страница localhost:3456 не открывается + +**Причина**: Контейнер не запущен. + +**Решение**: + +```bash +# Проверьте статус +docker ps + +# Если контейнера нет в списке — запустите +docker compose up -d +``` + +### "Connection refused" — соединение отклонено + +**Причина**: Контейнер не запущен или VPN-ссылка не применена. + +**Решение**: + +1. Проверьте, запущен ли контейнер: `docker ps` +2. Откройте http://localhost:3456 и примените VPN-ссылку + +### VS Code не подключается / очень медленно + +**Причина**: Системный VPN или прокси мешает. + +**Решение**: + +1. Выключите системный VPN +2. Удалите переменные окружения `HTTP_PROXY` и `HTTPS_PROXY` (если есть) +3. Перезапустите VS Code + +### Не открываются сайты / SSL ошибки + +**Причина**: Проблемы с настройками VPN-сервера. + +**Решение**: + +- Попробуйте другой сервер — вставьте другую ссылку в веб-интерфейсе +- Проверьте, что ссылка подписки актуальна + +### Как узнать, работает ли VPN? + +```bash +# Без прокси — покажет ваш домашний IP +curl https://ipinfo.io/json + +# Через прокси — должен показать IP VPN-сервера +curl -x http://127.0.0.1:8082 https://ipinfo.io/json +``` + +Если IP-адреса разные — VPN работает! 🎉 + +--- + +## 🔧 Для продвинутых пользователей + +### Запуск с VPN-ссылкой при старте + +Если хотите сразу применить ссылку при запуске (без веб-интерфейса): + +```bash +VLESS_URL="vless://..." docker compose up -d +``` + +### Запуск без Docker + +Если вы не хотите использовать Docker: + +1. Установите [sing-box](https://sing-box.sagernet.org/) +2. Сгенерируйте конфигурацию: + ```bash + ./gen-client-from-url.sh "vless://..." client.json + ``` +3. Запустите: + ```bash + sing-box run -c client.json + ``` + +### Автоматическое обновление конфигурации + +Контейнер автоматически обновляет конфигурацию каждые 60 минут. Чтобы изменить интервал, добавьте в `docker-compose.yml`: + +```yaml +environment: + UPDATE_INTERVAL: 120 # обновлять каждые 120 минут +``` + +--- + +## 📚 Словарь терминов + +| Термин | Объяснение | +| ------------------- | ----------------------------------------------------------------------------- | +| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени | +| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером | +| **Docker** | Программа для запуска приложений в изолированных "контейнерах" | +| **Контейнер** | Изолированное приложение со всеми необходимыми компонентами | +| **VLESS** | Современный протокол VPN-соединения | +| **Reality** | Технология маскировки VPN-трафика под обычный интернет-трафик | +| **Ссылка подписки** | URL, который содержит список VPN-серверов и их настройки | +| **Порт** | "Номер двери" для сетевых соединений. Прокси: 8082, Веб-интерфейс: 3456 | + +--- + +## 🆘 Нужна помощь? + +Если что-то не работает: + +1. Проверьте статус: `docker ps` +2. Проверьте логи: `docker logs --tail 100 sing-proxy` +3. Убедитесь, что VPN-ссылка актуальна +4. Попробуйте пересобрать: `docker compose down && docker compose build --no-cache && docker compose up -d` + +--- + +_Создано для простого и безопасного доступа в интернет_ 🛡️ diff --git a/config/client.template.json b/config/client.template.json new file mode 100644 index 0000000..2445dbd --- /dev/null +++ b/config/client.template.json @@ -0,0 +1,52 @@ +{ + "log": { + "level": "info", + "timestamp": true + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen": "0.0.0.0", + "listen_port": 8082, + "sniff": true, + "sniff_override_destination": true + } + ], + "outbounds": [ + { + "type": "vless", + "tag": "__TAG__", + "server": "__SERVER__", + "server_port": 0, + "uuid": "__UUID__", + "flow": "", + "tls": { + "enabled": true, + "server_name": "__SNI__", + "utls": { + "enabled": true, + "fingerprint": "__FINGERPRINT__" + }, + "reality": { + "enabled": true, + "public_key": "__PUBLIC_KEY__", + "short_id": "__SHORT_ID__" + } + }, + "packet_encoding": "xudp" + }, + { + "type": "direct", + "tag": "direct" + }, + { + "type": "block", + "tag": "block" + } + ], + "route": { + "final": "__TAG__", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1457da9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3.9" +services: + sing-proxy: + container_name: sing-proxy + build: + context: . + dockerfile: docker/Dockerfile.singbox + args: + # Передайте URL через переменную окружения VLESS_URL при сборке: + # export VLESS_URL="vless://..." + VLESS_URL: ${VLESS_URL-} + ports: + - "8082:8082" + - "3456:3456" + volumes: + - ./data:/app/data + restart: unless-stopped + deploy: + resources: + limits: + memory: 256m + # Для Docker Desktop лимит также можно задать через mem_limit (устаревшее поле, но работает вне swarm) + # mem_limit: 256m diff --git a/docker/Dockerfile.singbox b/docker/Dockerfile.singbox new file mode 100644 index 0000000..acaedc3 --- /dev/null +++ b/docker/Dockerfile.singbox @@ -0,0 +1,19 @@ +FROM alpine:3.20 +ARG SINGBOX_VER=1.8.10 +ARG VLESS_URL +RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd python3 && update-ca-certificates \ + && curl -L -o /tmp/sb.tar.gz https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VER}/sing-box-${SINGBOX_VER}-linux-amd64.tar.gz \ + && tar -xf /tmp/sb.tar.gz -C /tmp \ + && mv /tmp/sing-box-${SINGBOX_VER}-linux-amd64/sing-box /usr/local/bin/sing-box \ + && chmod +x /usr/local/bin/sing-box \ + && adduser -D -u 1000 suser +COPY --chown=suser:suser config/client.template.json /app/ +COPY --chown=suser:suser scripts/gen-client-from-url.sh scripts/menu.sh /app/ +COPY --chown=suser:suser docker/entrypoint.sh /app/ +COPY --chown=suser:suser web/ /app/web/ +RUN chmod +x /app/gen-client-from-url.sh /app/entrypoint.sh /app/menu.sh + +ENV VLESS_URL=$VLESS_URL + +EXPOSE 8082 9090 3456 +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..99c6742 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -e + +# Default update interval: 60 minutes +UPDATE_INTERVAL=${UPDATE_INTERVAL:-60} +CONFIG_FILE="/app/data/client.json" +SINGBOX_PID="" + +# Ensure data directory exists +mkdir -p /app/data + +# Function to generate config +generate_config() { + echo "$(date): Generating config..." + if ./gen-client-from-url.sh "$VLESS_URL" "$CONFIG_FILE"; then + echo "$(date): Config generated successfully." + return 0 + else + echo "$(date): Error generating config." + return 1 + fi +} + +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 :3456 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 +} + +# Initial generation (if URL provided) +if [[ -n "$VLESS_URL" ]]; then + generate_config +fi + +start_singbox + +# Start Web UI Server +echo "$(date): Starting Web UI on port 3456..." +python3 /app/web/server.py & +WEBUI_PID=$! + +# HTTP Control Server (Simple Netcat loop) +# Listens on 9090. +# Endpoints: +# /update -> Regenerate from ENV (VLESS_URL) & Restart +# /reload -> Just Restart (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 /update"; then + echo "$(date): Action: UPDATE (Regen from ENV + Restart)" + if generate_config; then + restart_singbox + fi + elif echo "$REQ" | grep -q "GET /reload"; then + echo "$(date): Action: RELOAD (Restart only)" + restart_singbox + else + echo "$(date): Unknown request or ping." + fi + done +) & +CONTROL_PID=$! + +# Periodic Update Loop (only if VLESS_URL is set) +if [[ -n "$VLESS_URL" ]]; then + ( + while true; do + sleep "$((UPDATE_INTERVAL * 60))" + echo "$(date): Checking for periodic update..." + if generate_config; then + restart_singbox + fi + done + ) & + UPDATE_PID=$! +fi + +# Keep container alive - wait for any background process +echo "$(date): Entrypoint ready. Waiting for processes..." + +# Wait indefinitely - if WebUI dies, restart container +wait $WEBUI_PID diff --git a/scripts/gen-client-from-url.sh b/scripts/gen-client-from-url.sh new file mode 100755 index 0000000..b5ed30d --- /dev/null +++ b/scripts/gen-client-from-url.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./gen-client-from-url.sh "vless://uuid@host:443?type=tcp&security=reality&pbk=PUBLIC_KEY&fp=random&sni=yahoo.com&sid=SHORTID&spx=%2F&flow=xtls-rprx-vision#tag" [output.json] +# If output not set, defaults to client.json + +URL_INPUT=${1:-} +OUT_FILE=${2:-client.json} +TEMPLATE_DIR="$(cd "$(dirname "$0")" && pwd)" +TEMPLATE_FILE="$TEMPLATE_DIR/client.template.json" + +if [[ -z "$URL_INPUT" ]]; then + echo "Error: provide VLESS reality URL or Subscription URL" >&2 + exit 1 +fi + +if [[ ! -f "$TEMPLATE_FILE" ]]; then + echo "Template not found: $TEMPLATE_FILE" >&2 + exit 1 +fi + +# Detect if input is a subscription link (HTTP/HTTPS) +if [[ "$URL_INPUT" =~ ^http ]]; then + echo "Detecting subscription link..." + + # Build URL with client parameter for APIs that require it + SUB_URL="$URL_INPUT" + + # Try fetching as-is first + SUB_CONTENT=$(curl -sSL "$SUB_URL") + + # If empty, try adding client parameter (some APIs require this) + if [[ -z "$SUB_CONTENT" ]]; then + echo "Empty response, trying with client=v2rayng parameter..." + if [[ "$SUB_URL" == *"?"* ]]; then + SUB_URL="${URL_INPUT}&client=v2rayng" + else + SUB_URL="${URL_INPUT}?client=v2rayng" + fi + SUB_CONTENT=$(curl -sSL "$SUB_URL") + fi + + if [[ -z "$SUB_CONTENT" ]]; then + echo "Error: Failed to download subscription from $SUB_URL" >&2 + exit 1 + fi + + # Check if base64 encoded (simple check: no spaces, looks like b64) + # Trying to decode. If fails, assume it's plain text lists + if DECODED=$(echo "$SUB_CONTENT" | base64 -d 2>/dev/null); then + echo "Decoded base64 subscription." + RAW_CONFIGS="$DECODED" + else + echo "Using plain text subscription." + RAW_CONFIGS="$SUB_CONTENT" + fi + + # Find first vless reality link (vless://... + security=reality or just vless://) + # We try to find one that explicitly has reality, if not, pick ANY vless + TARGET_URL=$(echo "$RAW_CONFIGS" | grep -o 'vless://[^[:space:]]*' | grep 'security=reality' | head -n 1) + + if [[ -z "$TARGET_URL" ]]; then + echo "No VLESS Reality link found, trying any VLESS..." + TARGET_URL=$(echo "$RAW_CONFIGS" | grep -o 'vless://[^[:space:]]*' | head -n 1) + fi + + if [[ -z "$TARGET_URL" ]]; then + echo "Error: No VLESS URL found in subscription." >&2 + exit 1 + fi + + echo "Selected URL from subscription: ${TARGET_URL:0:30}..." + URL_INPUT="$TARGET_URL" +fi + +# Strip scheme +URL_NOSCHEME=${URL_INPUT#vless://} + +UUID_HOST_PORT=${URL_NOSCHEME%%\?*} +QUERY_AND_TAG=${URL_NOSCHEME#*?} +QUERY=${QUERY_AND_TAG%%#*} +TAG_RAW=${URL_INPUT#*#} +TAG=${TAG_RAW:-reality} + +UUID=${UUID_HOST_PORT%%@*} +HOST_PORT=${UUID_HOST_PORT#*@} +HOST=${HOST_PORT%%:*} +PORT=${HOST_PORT##*:} + +# Parse query params (portable, no associative arrays) +PBK=""; FINGERPRINT="chrome"; SNI=""; SHORT_ID=""; SPX=""; FLOW="" +OLD_IFS=$IFS +IFS='&' +set +u +for kv in $QUERY; do + key=${kv%%=*} + val=${kv#*=} + case "$key" in + pbk) PBK=$val ;; + fp) FINGERPRINT=$val ;; + sni) SNI=$val ;; + sid) SHORT_ID=$val ;; + spx) SPX=$val ;; + flow) FLOW=$val ;; + esac +done +set -u +IFS=$OLD_IFS +SNI=${SNI:-$HOST} +# SPX currently not used + +if [[ -z "$UUID" || -z "$HOST" || -z "$PORT" || -z "$PBK" || -z "$SHORT_ID" ]]; then + echo "Missing required fields (uuid/host/port/pbk/sid)" >&2 + exit 1 +fi + +TMP=$(mktemp) +cp "$TEMPLATE_FILE" "$TMP" + +# Perform replacements safely using jq +# Replace simple placeholders +jq \ + --arg uuid "$UUID" \ + --arg server "$HOST" \ + --argjson port "$PORT" \ + --arg tag "$TAG" \ + --arg sni "$SNI" \ + --arg fp "$FINGERPRINT" \ + --arg pk "$PBK" \ + --arg sid "$SHORT_ID" \ + --arg flow "$FLOW" ' + (.outbounds[] | select(.type=="vless")) as $v | ( + .outbounds |= map(if .type=="vless" then ( + .uuid=$uuid + | .server=$server + | .server_port=$port + | .tag=$tag + | .tls.server_name=$sni + | .tls.utls.fingerprint=$fp + | .tls.reality.public_key=$pk + | .tls.reality.short_id=$sid + | .flow=$flow + ) else . end) + | .route.final=$tag + )' "$TMP" > "$OUT_FILE" + +rm "$TMP" + +echo "Generated $OUT_FILE from URL (tag=$TAG)" diff --git a/scripts/menu.sh b/scripts/menu.sh new file mode 100644 index 0000000..197b839 --- /dev/null +++ b/scripts/menu.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -u + +URL_INPUT=${1:-} +CONFIG_FILE="client.json" + +if [[ -z "$URL_INPUT" ]]; then + echo "Usage: ./menu.sh " + exit 1 +fi + +# Function to decode URL params specially for VLESS +decode_url() { + local encoded="$1" + # Basic URL decode + echo -e "${encoded//%/\\x}" +} + +# 1. Detect type +if [[ "$URL_INPUT" =~ ^vless:// ]]; then + echo "Direct VLESS URL detected. Applying..." + ./gen-client-from-url.sh "$URL_INPUT" "$CONFIG_FILE" + echo "Triggering reload..." + curl -s http://localhost:9090/reload + echo "Done." + exit 0 +fi + +# 2. It's likely a subscription +echo "Fetching subscription..." +SUB_CONTENT=$(curl -sSL "$URL_INPUT") + +if [[ -z "$SUB_CONTENT" ]]; then + echo "Error: Empty response." + exit 1 +fi + +# Try Base64 decode +if DECODED=$(echo "$SUB_CONTENT" | base64 -d 2>/dev/null); then + echo "Subscription is Base64 encoded." + RAW_LIST="$DECODED" +else + echo "Subscription is plain text." + RAW_LIST="$SUB_CONTENT" +fi + +# 3. Parse VLESS links +# We will use an array to store links and names +declare -a LINKS +declare -a NAMES + +i=0 +while IFS= read -r line; do + # trimming + line=$(echo "$line" | xargs) + if [[ "$line" =~ ^vless:// ]]; then + LINKS[$i]="$line" + + # Extract name from hash #Name + if [[ "$line" =~ \#(.*)$ ]]; then + NAME=$(decode_url "${BASH_REMATCH[1]}") + else + NAME="Config_$((i+1))" + fi + NAMES[$i]="$NAME" + ((i++)) + fi +done <<< "$RAW_LIST" + +COUNT=${#LINKS[@]} + +if [[ "$COUNT" -eq 0 ]]; then + echo "No VLESS configs found in subscription." + exit 1 +fi + +# 4. Display Menu +echo "Found $COUNT configurations:" +echo "--------------------------------" +for (( j=0; j + + + + + VPN Proxy Control + + + +
+
+
+ +

VPN Proxy Control

+

Управление подключением sing-box

+
+ +
+
+
+
Статус
+
Загрузка...
+
+
+ +
+
+ +
+ +
+

Вставьте VLESS ссылку или URL подписки

+
+ + +
+ +
+ + +
+
+ + +
+ + + + diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..94037f1 --- /dev/null +++ b/web/server.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Simple HTTP Web Server for VPN Proxy Control +Provides a web UI to apply VLESS/subscription URLs +""" + +import http.server +import json +import os +import subprocess +import socketserver +from urllib.parse import parse_qs +from pathlib import Path + +PORT = 3456 +APP_DIR = Path(__file__).parent +WEB_DIR = APP_DIR / "web" +DATA_DIR = APP_DIR / "data" +CONFIG_FILE = DATA_DIR / "client.json" + + +class ProxyControlHandler(http.server.BaseHTTPRequestHandler): + """HTTP Request Handler for Proxy Control""" + + def log_message(self, format, *args): + """Override to add timestamp prefix""" + print(f"[WebUI] {args[0]}") + + def send_json(self, data: dict, status: int = 200): + """Send JSON response""" + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8")) + + def send_html(self, content: bytes): + """Send HTML response""" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(content) + + def do_GET(self): + """Handle GET requests""" + if self.path == "/" or self.path == "/index.html": + self.serve_index() + elif self.path == "/status": + self.get_status() + elif self.path.startswith("/static/"): + self.serve_static() + else: + self.send_error(404) + + def do_POST(self): + """Handle POST requests""" + if self.path == "/apply": + self.apply_config() + else: + self.send_error(404) + + def serve_index(self): + """Serve main HTML page""" + index_path = WEB_DIR / "index.html" + if index_path.exists(): + self.send_html(index_path.read_bytes()) + else: + self.send_error(404, "index.html not found") + + def serve_static(self): + """Serve static files""" + file_path = WEB_DIR / self.path[8:] # Remove /static/ + if file_path.exists() and file_path.is_file(): + content_type = "text/css" if str(file_path).endswith(".css") else "application/javascript" + self.send_response(200) + self.send_header("Content-Type", content_type) + self.end_headers() + self.wfile.write(file_path.read_bytes()) + else: + self.send_error(404) + + def get_status(self): + """Get current proxy status""" + config_exists = CONFIG_FILE.exists() + current_tag = None + current_server = None + + if config_exists: + try: + config = json.loads(CONFIG_FILE.read_text()) + for outbound in config.get("outbounds", []): + if outbound.get("type") == "vless": + current_tag = outbound.get("tag", "unknown") + current_server = outbound.get("server", "unknown") + break + except Exception: + pass + + self.send_json({ + "active": config_exists, + "tag": current_tag, + "server": current_server + }) + + def apply_config(self): + """Apply new config from URL""" + try: + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length).decode("utf-8") + data = json.loads(body) + url = data.get("url", "").strip() + + if not url: + self.send_json({"success": False, "error": "URL не указан"}, 400) + return + + if not (url.startswith("vless://") or url.startswith("http://") or url.startswith("https://")): + self.send_json({"success": False, "error": "Неверный формат URL. Ожидается vless:// или http(s):// ссылка"}, 400) + return + + # Run gen-client-from-url.sh + script_path = APP_DIR / "gen-client-from-url.sh" + result = subprocess.run( + [str(script_path), url, str(CONFIG_FILE)], + capture_output=True, + text=True, + cwd=str(APP_DIR), + timeout=30 + ) + + if result.returncode != 0: + error_msg = result.stderr or result.stdout or "Неизвестная ошибка" + self.send_json({"success": False, "error": f"Ошибка генерации: {error_msg}"}, 500) + return + + # Trigger reload via internal control port + try: + import urllib.request + urllib.request.urlopen("http://localhost:9090/reload", timeout=5) + except Exception as e: + print(f"[WebUI] Warning: reload request failed: {e}") + # Continue anyway, config is generated + + self.send_json({ + "success": True, + "message": "Конфигурация применена успешно!", + "output": result.stdout + }) + + except json.JSONDecodeError: + self.send_json({"success": False, "error": "Неверный JSON"}, 400) + except subprocess.TimeoutExpired: + self.send_json({"success": False, "error": "Таймаут при генерации конфига"}, 500) + except Exception as e: + self.send_json({"success": False, "error": str(e)}, 500) + + +def main(): + """Start the web server""" + with socketserver.TCPServer(("", PORT), ProxyControlHandler) as httpd: + print(f"[WebUI] Server started on port {PORT}") + print(f"[WebUI] Open http://localhost:{PORT} in your browser") + httpd.serve_forever() + + +if __name__ == "__main__": + main()