refactor: реорганизация структуры проекта на логические папки
- Созданы директории: docker/, scripts/, config/ - Перемещены файлы Docker (Dockerfile, entrypoint.sh) в docker/ - Перемещены утилитарные скрипты в scripts/ - Шаблон конфигурации перенесен в config/ - Веб-сервер перемещен в web/ и переименован в server.py - Обновлены пути в docker-compose.yml, Dockerfile и entrypoint.sh
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data
|
||||||
330
README.md
330
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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Создано для простого и безопасного доступа в интернет_ 🛡️
|
||||||
|
|||||||
52
config/client.template.json
Normal file
52
config/client.template.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -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
|
||||||
19
docker/Dockerfile.singbox
Normal file
19
docker/Dockerfile.singbox
Normal file
@@ -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"]
|
||||||
106
docker/entrypoint.sh
Normal file
106
docker/entrypoint.sh
Normal file
@@ -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
|
||||||
149
scripts/gen-client-from-url.sh
Executable file
149
scripts/gen-client-from-url.sh
Executable file
@@ -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)"
|
||||||
101
scripts/menu.sh
Normal file
101
scripts/menu.sh
Normal file
@@ -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 <VLESS_URL_or_SUBSCRIPTION_URL>"
|
||||||
|
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<COUNT; j++ )); do
|
||||||
|
echo "$((j+1))) ${NAMES[$j]}"
|
||||||
|
done
|
||||||
|
echo "--------------------------------"
|
||||||
|
read -p "Select config (1-$COUNT): " SELECTION
|
||||||
|
|
||||||
|
if ! [[ "$SELECTION" =~ ^[0-9]+$ ]] || [ "$SELECTION" -lt 1 ] || [ "$SELECTION" -gt "$COUNT" ]; then
|
||||||
|
echo "Invalid selection."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
INDEX=$((SELECTION-1))
|
||||||
|
SELECTED_URL=${LINKS[$INDEX]}
|
||||||
|
|
||||||
|
echo "Selected: ${NAMES[$INDEX]}"
|
||||||
|
echo "Applying..."
|
||||||
|
|
||||||
|
./gen-client-from-url.sh "$SELECTED_URL" "$CONFIG_FILE"
|
||||||
|
|
||||||
|
echo "Triggering process reload..."
|
||||||
|
curl -s http://localhost:9090/reload
|
||||||
|
echo "Success! Proxy updated."
|
||||||
448
web/index.html
Normal file
448
web/index.html
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VPN Proxy Control</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--text-primary: #e8e8ec;
|
||||||
|
--text-secondary: #8b8b9e;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-glow: rgba(99, 102, 241, 0.4);
|
||||||
|
--success: #22c55e;
|
||||||
|
--error: #ef4444;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at top, rgba(99, 102, 241, 0.1) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at bottom right, rgba(168, 85, 247, 0.08) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||||
|
0 2px 4px -2px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #a855f7);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1.25rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
box-shadow: 0 8px 32px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.active {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 12px rgba(34, 197, 94, 0.5);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 20px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">🔐</div>
|
||||||
|
<h1>VPN Proxy Control</h1>
|
||||||
|
<p class="subtitle">Управление подключением sing-box</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar" id="statusBar">
|
||||||
|
<div class="status-indicator" id="statusIndicator"></div>
|
||||||
|
<div class="status-text">
|
||||||
|
<div class="status-label">Статус</div>
|
||||||
|
<div class="status-value" id="statusValue">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="proxyForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="urlInput">VLESS / Subscription URL</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="urlInput"
|
||||||
|
placeholder="vless://... или https://subscription.link"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Вставьте VLESS ссылку или URL подписки</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span id="btnText">Применить</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="message" id="message">
|
||||||
|
<span class="message-icon" id="messageIcon"></span>
|
||||||
|
<span id="messageText"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Proxy работает на порту <strong>8082</strong> •
|
||||||
|
<a href="https://github.com/SagerNet/sing-box" target="_blank">sing-box</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('proxyForm');
|
||||||
|
const urlInput = document.getElementById('urlInput');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const btnText = document.getElementById('btnText');
|
||||||
|
const message = document.getElementById('message');
|
||||||
|
const messageIcon = document.getElementById('messageIcon');
|
||||||
|
const messageText = document.getElementById('messageText');
|
||||||
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
|
const statusValue = document.getElementById('statusValue');
|
||||||
|
|
||||||
|
// Fetch initial status
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/status');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.active) {
|
||||||
|
statusIndicator.classList.add('active');
|
||||||
|
statusValue.textContent = data.tag
|
||||||
|
? `${data.tag} (${data.server})`
|
||||||
|
: 'Активен';
|
||||||
|
} else {
|
||||||
|
statusIndicator.classList.remove('active');
|
||||||
|
statusValue.textContent = 'Не настроен';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusValue.textContent = 'Ошибка загрузки';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(type, text) {
|
||||||
|
message.className = `message show ${type}`;
|
||||||
|
messageIcon.textContent = type === 'success' ? '✓' : '✕';
|
||||||
|
messageText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
submitBtn.disabled = loading;
|
||||||
|
if (loading) {
|
||||||
|
btnText.innerHTML = '<div class="spinner"></div>';
|
||||||
|
} else {
|
||||||
|
btnText.textContent = 'Применить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
showMessage('error', 'Введите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.className = 'message';
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('success', data.message || 'Конфигурация применена!');
|
||||||
|
urlInput.value = '';
|
||||||
|
fetchStatus();
|
||||||
|
} else {
|
||||||
|
showMessage('error', data.error || 'Произошла ошибка');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchStatus();
|
||||||
|
// Refresh status every 30 seconds
|
||||||
|
setInterval(fetchStatus, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
web/server.py
Normal file
167
web/server.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user