feat: Добавлена веб-панель управления VPN-прокси и Docker-конфигурация.
This commit is contained in:
54
README.md
54
README.md
@@ -40,16 +40,13 @@
|
|||||||
|
|
||||||
## 📦 Что внутри?
|
## 📦 Что внутри?
|
||||||
|
|
||||||
| Файл | Описание простыми словами |
|
| Файл | Описание простыми словами |
|
||||||
| ------------------------ | ------------------------------------------------------------------------ |
|
| ----------------------- | ------------------------------------------------------------------ |
|
||||||
| `web_server.py` | Веб-интерфейс для управления через браузер |
|
| `web/server.py` | Веб-интерфейс для управления через браузер |
|
||||||
| `web/index.html` | Страница с красивым интерфейсом |
|
| `web/index.html` | Страница с красивым интерфейсом |
|
||||||
| `client.template.json` | Шаблон настроек — как "бланк анкеты", который заполняется вашими данными |
|
| `docker/entrypoint.sh` | Главный скрипт запуска контейнера |
|
||||||
| `gen-client-from-url.sh` | Скрипт, который берёт вашу VPN-ссылку и заполняет "анкету" |
|
| `docker/Dockerfile` | Инструкция для создания изолированного VPN-приложения (контейнера) |
|
||||||
| `menu.sh` | Интерактивное меню для выбора сервера из списка (консольная версия) |
|
| `docker-compose.yml` | Файл для удобного запуска одной командой |
|
||||||
| `entrypoint.sh` | Главный скрипт запуска с функцией авто-обновления |
|
|
||||||
| `Dockerfile.singbox` | Инструкция для создания изолированного VPN-приложения (контейнера) |
|
|
||||||
| `docker-compose.yml` | Файл для удобного запуска одной командой |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,7 +54,9 @@
|
|||||||
|
|
||||||
### Что вам понадобится
|
### Что вам понадобится
|
||||||
|
|
||||||
1. **VLESS-ссылка** — получите её от вашего VPN-провайдера. Она начинается с `vless://...`
|
1. **URL подписки** или **VLESS-ссылка** — получите её от вашего VPN-провайдера
|
||||||
|
- Подписка: формат sing-box
|
||||||
|
- VLESS: начинается с `vless://...`
|
||||||
|
|
||||||
2. **Docker** — программа для запуска изолированных приложений
|
2. **Docker** — программа для запуска изолированных приложений
|
||||||
- [Скачать Docker Desktop](https://www.docker.com/products/docker-desktop/) (бесплатно)
|
- [Скачать Docker Desktop](https://www.docker.com/products/docker-desktop/) (бесплатно)
|
||||||
@@ -88,9 +87,12 @@ docker compose up -d
|
|||||||
### После запуска
|
### После запуска
|
||||||
|
|
||||||
1. **Откройте веб-интерфейс**: http://localhost:3456
|
1. **Откройте веб-интерфейс**: http://localhost:3456
|
||||||
2. **Вставьте вашу VLESS-ссылку** (vless://)
|
2. **Выберите режим**:
|
||||||
3. **Нажмите "Применить"**
|
- **📡 Подписка**: вставьте URL подписки, нажмите «Загрузить серверы», выберите сервер и нажмите «Применить»
|
||||||
4. Готово! Прокси работает на порту **8082**
|
- **🔑 VLESS Ключ**: вставьте VLESS-ссылку и нажмите «Применить»
|
||||||
|
3. Готово! Прокси работает на порту **8082**
|
||||||
|
|
||||||
|
> 💡 **Подписка сохраняется** между перезагрузками контейнера — URL и выбранный сервер хранятся в папке `data/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ docker compose build --no-cache
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 **Примечание:** после пересборки нужно снова применить VPN-ссылку через веб-интерфейс http://localhost:3456
|
> 💡 **Примечание:** подписка сохраняется и будет автоматически загружена при открытии веб-интерфейса http://localhost:3456
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -256,37 +258,17 @@ curl -x http://127.0.0.1:8082 https://ipinfo.io/json
|
|||||||
|
|
||||||
## 🔧 Для продвинутых пользователей
|
## 🔧 Для продвинутых пользователей
|
||||||
|
|
||||||
### Запуск с VPN-ссылкой при старте
|
|
||||||
|
|
||||||
Если хотите сразу применить ссылку при запуске (без веб-интерфейса):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
VLESS_URL="vless://..." docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск без Docker
|
### Запуск без Docker
|
||||||
|
|
||||||
Если вы не хотите использовать Docker:
|
Если вы не хотите использовать Docker:
|
||||||
|
|
||||||
1. Установите [sing-box](https://sing-box.sagernet.org/)
|
1. Установите [sing-box](https://sing-box.sagernet.org/)
|
||||||
2. Сгенерируйте конфигурацию:
|
2. Скопируйте конфигурацию из веб-интерфейса подписки и сохраните в `client.json`
|
||||||
```bash
|
|
||||||
./gen-client-from-url.sh "vless://..." client.json
|
|
||||||
```
|
|
||||||
3. Запустите:
|
3. Запустите:
|
||||||
```bash
|
```bash
|
||||||
sing-box run -c client.json
|
sing-box run -c client.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Автоматическое обновление конфигурации
|
|
||||||
|
|
||||||
Контейнер автоматически обновляет конфигурацию каждые 60 минут. Чтобы изменить интервал, добавьте в `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
UPDATE_INTERVAL: 120 # обновлять каждые 120 минут
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Словарь терминов
|
## 📚 Словарь терминов
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
ARG SINGBOX_VER=1.8.10
|
ARG SINGBOX_VER=1.8.10
|
||||||
ARG VLESS_URL
|
|
||||||
|
|
||||||
# Устанавливаем зависимости, включая dos2unix для исправления скриптов
|
# Устанавливаем зависимости, включая dos2unix для исправления скриптов
|
||||||
RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd python3 dos2unix && update-ca-certificates
|
RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd python3 dos2unix && update-ca-certificates
|
||||||
@@ -16,14 +15,11 @@ RUN ARCH=$(uname -m) && \
|
|||||||
&& chmod +x /usr/local/bin/sing-box \
|
&& chmod +x /usr/local/bin/sing-box \
|
||||||
&& adduser -D -u 1000 suser
|
&& 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 docker/entrypoint.sh /app/
|
||||||
COPY --chown=suser:suser web/ /app/web/
|
COPY --chown=suser:suser web/ /app/web/
|
||||||
|
|
||||||
# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск
|
# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск
|
||||||
RUN dos2unix /app/*.sh && chmod +x /app/gen-client-from-url.sh /app/entrypoint.sh /app/menu.sh
|
RUN dos2unix /app/*.sh && chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
ENV VLESS_URL=$VLESS_URL
|
|
||||||
EXPOSE 8082 9090 3456
|
EXPOSE 8082 9090 3456
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -1,26 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Default update interval: 60 minutes
|
|
||||||
UPDATE_INTERVAL=${UPDATE_INTERVAL:-60}
|
|
||||||
CONFIG_FILE="/app/data/client.json"
|
CONFIG_FILE="/app/data/client.json"
|
||||||
SINGBOX_PID=""
|
SINGBOX_PID=""
|
||||||
|
|
||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
mkdir -p /app/data
|
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() {
|
start_singbox() {
|
||||||
if [[ -f "$CONFIG_FILE" ]]; then
|
if [[ -f "$CONFIG_FILE" ]]; then
|
||||||
echo "$(date): Starting sing-box..."
|
echo "$(date): Starting sing-box..."
|
||||||
@@ -47,11 +33,6 @@ restart_singbox() {
|
|||||||
start_singbox
|
start_singbox
|
||||||
}
|
}
|
||||||
|
|
||||||
# Initial generation (if URL provided)
|
|
||||||
if [[ -n "$VLESS_URL" ]]; then
|
|
||||||
generate_config
|
|
||||||
fi
|
|
||||||
|
|
||||||
start_singbox
|
start_singbox
|
||||||
|
|
||||||
# Start Web UI Server
|
# Start Web UI Server
|
||||||
@@ -61,22 +42,15 @@ WEBUI_PID=$!
|
|||||||
|
|
||||||
# HTTP Control Server (Simple Netcat loop)
|
# HTTP Control Server (Simple Netcat loop)
|
||||||
# Listens on 9090.
|
# Listens on 9090.
|
||||||
# Endpoints:
|
# Endpoint: /reload -> Restart sing-box (used by web_server.py after config change)
|
||||||
# /update -> Regenerate from ENV (VLESS_URL) & Restart
|
|
||||||
# /reload -> Just Restart (used by web_server.py after config change)
|
|
||||||
(
|
(
|
||||||
while true; do
|
while true; do
|
||||||
# Read the request using nc.
|
# 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)
|
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"
|
echo "$(date): Received request on 9090"
|
||||||
|
|
||||||
if echo "$REQ" | grep -q "GET /update"; then
|
if echo "$REQ" | grep -q "GET /reload"; then
|
||||||
echo "$(date): Action: UPDATE (Regen from ENV + Restart)"
|
echo "$(date): Action: RELOAD (Restart sing-box)"
|
||||||
if generate_config; then
|
|
||||||
restart_singbox
|
|
||||||
fi
|
|
||||||
elif echo "$REQ" | grep -q "GET /reload"; then
|
|
||||||
echo "$(date): Action: RELOAD (Restart only)"
|
|
||||||
restart_singbox
|
restart_singbox
|
||||||
else
|
else
|
||||||
echo "$(date): Unknown request or ping."
|
echo "$(date): Unknown request or ping."
|
||||||
@@ -85,20 +59,6 @@ WEBUI_PID=$!
|
|||||||
) &
|
) &
|
||||||
CONTROL_PID=$!
|
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
|
# Keep container alive - wait for any background process
|
||||||
echo "$(date): Entrypoint ready. Waiting for processes..."
|
echo "$(date): Entrypoint ready. Waiting for processes..."
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
# Check if input starts with vless://
|
|
||||||
if [[ "$URL_INPUT" != vless://* ]]; then
|
|
||||||
echo "Error: Only vless:// URLs are supported." >&2
|
|
||||||
exit 1
|
|
||||||
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
101
scripts/menu.sh
@@ -1,101 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
429
web/index.html
429
web/index.html
@@ -340,6 +340,163 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server List */
|
||||||
|
.server-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-radio {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item.selected .server-radio {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item.selected .server-radio::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-details {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
body {
|
body {
|
||||||
@@ -354,6 +511,15 @@
|
|||||||
h1 {
|
h1 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -375,21 +541,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="proxyForm">
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="subscription">📡 Подписка</button>
|
||||||
|
<button class="tab" data-tab="vless">🔑 VLESS Ключ</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscription Tab Content -->
|
||||||
|
<div id="subscription-tab" class="tab-content active">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="urlInput">VLESS Key</label>
|
<label for="subUrlInput">URL подписки</label>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
|
<input type="text" id="subUrlInput" placeholder="https://..." autocomplete="off"
|
||||||
spellcheck="false">
|
spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Вставьте VLESS ссылку</p>
|
<p class="hint">Вставьте ссылку подписки (sing-box формат)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
<button type="button" class="btn btn-secondary" id="fetchServersBtn" style="margin-bottom: 1rem;">
|
||||||
<span class="btn-icon">⚡</span>
|
<span class="btn-icon">🔄</span>
|
||||||
<span id="btnText">Применить</span>
|
<span id="fetchBtnText">Загрузить серверы</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
<div id="serverListContainer" style="display: none;">
|
||||||
|
<label style="margin-bottom: 0.75rem; display: block;">Выберите сервер</label>
|
||||||
|
<div class="server-list" id="serverList"></div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary" id="applySubBtn">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span id="applySubBtnText">Применить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyServers" class="empty-state" style="display: none;">
|
||||||
|
<div class="empty-state-icon">📭</div>
|
||||||
|
<p>Серверы не загружены</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VLESS Tab Content -->
|
||||||
|
<div id="vless-tab" class="tab-content">
|
||||||
|
<form id="proxyForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="urlInput">VLESS Key</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
|
||||||
|
spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<p class="hint">Вставьте VLESS ссылку</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span id="btnText">Применить</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="message" id="message">
|
<div class="message" id="message">
|
||||||
<span class="message-icon" id="messageIcon"></span>
|
<span class="message-icon" id="messageIcon"></span>
|
||||||
@@ -416,6 +623,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// DOM Elements
|
||||||
const form = document.getElementById('proxyForm');
|
const form = document.getElementById('proxyForm');
|
||||||
const urlInput = document.getElementById('urlInput');
|
const urlInput = document.getElementById('urlInput');
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
@@ -426,6 +634,36 @@
|
|||||||
const statusIndicator = document.getElementById('statusIndicator');
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
const statusValue = document.getElementById('statusValue');
|
const statusValue = document.getElementById('statusValue');
|
||||||
|
|
||||||
|
// Subscription elements
|
||||||
|
const subUrlInput = document.getElementById('subUrlInput');
|
||||||
|
const fetchServersBtn = document.getElementById('fetchServersBtn');
|
||||||
|
const fetchBtnText = document.getElementById('fetchBtnText');
|
||||||
|
const serverListContainer = document.getElementById('serverListContainer');
|
||||||
|
const serverList = document.getElementById('serverList');
|
||||||
|
const emptyServers = document.getElementById('emptyServers');
|
||||||
|
const applySubBtn = document.getElementById('applySubBtn');
|
||||||
|
const applySubBtnText = document.getElementById('applySubBtnText');
|
||||||
|
|
||||||
|
// State
|
||||||
|
let subscriptionConfig = null;
|
||||||
|
let selectedServer = null;
|
||||||
|
let subscriptionUrl = null;
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
tab.classList.add('active');
|
||||||
|
const targetTab = tab.dataset.tab;
|
||||||
|
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
||||||
|
|
||||||
|
// Hide message when switching tabs
|
||||||
|
message.className = 'message';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch initial status
|
// Fetch initial status
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -452,15 +690,16 @@
|
|||||||
messageText.textContent = text;
|
messageText.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLoading(loading) {
|
function setLoading(btn, textEl, loading, defaultText = 'Применить') {
|
||||||
submitBtn.disabled = loading;
|
btn.disabled = loading;
|
||||||
if (loading) {
|
if (loading) {
|
||||||
btnText.innerHTML = '<div class="spinner"></div>';
|
textEl.innerHTML = '<div class="spinner"></div>';
|
||||||
} else {
|
} else {
|
||||||
btnText.textContent = 'Применить';
|
textEl.textContent = defaultText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VLESS form submit
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -471,7 +710,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
message.className = 'message';
|
message.className = 'message';
|
||||||
setLoading(true);
|
setLoading(submitBtn, btnText, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/apply', {
|
const res = await fetch('/apply', {
|
||||||
@@ -492,12 +731,174 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
showMessage('error', `Ошибка сети: ${e.message}`);
|
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(submitBtn, btnText, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch servers from subscription
|
||||||
|
fetchServersBtn.addEventListener('click', async () => {
|
||||||
|
const url = subUrlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
showMessage('error', 'Введите URL подписки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.className = 'message';
|
||||||
|
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
|
||||||
|
|
||||||
|
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 && data.servers.length > 0) {
|
||||||
|
subscriptionConfig = data.config;
|
||||||
|
subscriptionUrl = url; // Save URL for persistence
|
||||||
|
renderServerList(data.servers);
|
||||||
|
serverListContainer.style.display = 'block';
|
||||||
|
emptyServers.style.display = 'none';
|
||||||
|
showMessage('success', `Найдено ${data.servers.length} сервер(ов)`);
|
||||||
|
} else {
|
||||||
|
serverListContainer.style.display = 'none';
|
||||||
|
emptyServers.style.display = 'block';
|
||||||
|
showMessage('error', data.error || 'Серверы не найдены');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render server list
|
||||||
|
function renderServerList(servers, savedServerTag = null) {
|
||||||
|
serverList.innerHTML = '';
|
||||||
|
selectedServer = null;
|
||||||
|
|
||||||
|
servers.forEach((server, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'server-item';
|
||||||
|
item.dataset.tag = server.tag;
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="server-radio"></div>
|
||||||
|
<div class="server-info">
|
||||||
|
<div class="server-name">${escapeHtml(server.tag)}</div>
|
||||||
|
<div class="server-details">${escapeHtml(server.server)}:${server.port}</div>
|
||||||
|
</div>
|
||||||
|
<span class="server-type">${server.type}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.server-item').forEach(i => i.classList.remove('selected'));
|
||||||
|
item.classList.add('selected');
|
||||||
|
selectedServer = server.tag;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select saved server or first by default
|
||||||
|
const shouldSelect = savedServerTag
|
||||||
|
? server.tag === savedServerTag
|
||||||
|
: index === 0;
|
||||||
|
|
||||||
|
if (shouldSelect) {
|
||||||
|
item.classList.add('selected');
|
||||||
|
selectedServer = server.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply subscription with selected server
|
||||||
|
applySubBtn.addEventListener('click', async () => {
|
||||||
|
if (!subscriptionConfig) {
|
||||||
|
showMessage('error', 'Сначала загрузите серверы');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedServer) {
|
||||||
|
showMessage('error', 'Выберите сервер');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.className = 'message';
|
||||||
|
setLoading(applySubBtn, applySubBtnText, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/apply-subscription', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
config: subscriptionConfig,
|
||||||
|
selectedServer: selectedServer,
|
||||||
|
subUrl: subscriptionUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('success', data.message || 'Конфигурация применена!');
|
||||||
|
fetchStatus();
|
||||||
|
} else {
|
||||||
|
showMessage('error', data.error || 'Произошла ошибка');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(applySubBtn, applySubBtnText, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved subscription
|
||||||
|
async function loadSavedSubscription() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/subscription');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.saved && data.url) {
|
||||||
|
subUrlInput.value = data.url;
|
||||||
|
subscriptionUrl = data.url;
|
||||||
|
|
||||||
|
// Auto-load servers from saved subscription
|
||||||
|
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
|
||||||
|
try {
|
||||||
|
const subRes = await fetch('/fetch-subscription', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: data.url })
|
||||||
|
});
|
||||||
|
const subData = await subRes.json();
|
||||||
|
|
||||||
|
if (subData.success && subData.servers && subData.servers.length > 0) {
|
||||||
|
subscriptionConfig = subData.config;
|
||||||
|
renderServerList(subData.servers, data.selectedServer);
|
||||||
|
serverListContainer.style.display = 'block';
|
||||||
|
emptyServers.style.display = 'none';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('No saved subscription found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
loadSavedSubscription();
|
||||||
// Refresh status every 30 seconds
|
// Refresh status every 30 seconds
|
||||||
setInterval(fetchStatus, 30000);
|
setInterval(fetchStatus, 30000);
|
||||||
|
|
||||||
|
|||||||
370
web/server.py
370
web/server.py
@@ -1,23 +1,197 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Simple HTTP Web Server for VPN Proxy Control
|
Simple HTTP Web Server for VPN Proxy Control
|
||||||
Provides a web UI to apply VLESS/subscription URLs
|
Provides a web UI to manage sing-box subscriptions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import platform
|
||||||
import socketserver
|
import socketserver
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import uuid
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PORT = 3456
|
PORT = 3456
|
||||||
|
APP_NAME = "VPN-Proxy-Control by Dokril"
|
||||||
APP_DIR = Path(__file__).parent
|
APP_DIR = Path(__file__).parent
|
||||||
BASE_DIR = APP_DIR.parent
|
BASE_DIR = APP_DIR.parent
|
||||||
WEB_DIR = APP_DIR
|
WEB_DIR = APP_DIR
|
||||||
DATA_DIR = BASE_DIR / "data"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
CONFIG_FILE = DATA_DIR / "client.json"
|
CONFIG_FILE = DATA_DIR / "client.json"
|
||||||
|
HWID_FILE = DATA_DIR / "hwid"
|
||||||
|
SUBSCRIPTION_FILE = DATA_DIR / "subscription.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_hwid() -> str:
|
||||||
|
"""Get or generate hardware ID"""
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if HWID_FILE.exists():
|
||||||
|
return HWID_FILE.read_text().strip()
|
||||||
|
|
||||||
|
# Generate new random HWID
|
||||||
|
hwid = uuid.uuid4().hex[:16]
|
||||||
|
HWID_FILE.write_text(hwid)
|
||||||
|
return hwid
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_info() -> dict:
|
||||||
|
"""Get system information for headers"""
|
||||||
|
system = platform.system().lower() # windows, linux, darwin
|
||||||
|
version = platform.release() # 10, 5.15.0, 22.0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"os": system,
|
||||||
|
"version": version
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_subscription(url: str, selected_server: str = None):
|
||||||
|
"""Save subscription URL and selected server to file"""
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"url": url,
|
||||||
|
"selectedServer": selected_server
|
||||||
|
}
|
||||||
|
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def load_subscription() -> dict:
|
||||||
|
"""Load subscription from file"""
|
||||||
|
if SUBSCRIPTION_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(SUBSCRIPTION_FILE.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vless_url(url: str) -> dict:
|
||||||
|
"""Parse VLESS URL and extract connection parameters"""
|
||||||
|
from urllib.parse import urlparse, parse_qs, unquote
|
||||||
|
|
||||||
|
if not url.startswith("vless://"):
|
||||||
|
raise ValueError("URL must start with vless://")
|
||||||
|
|
||||||
|
# Remove scheme
|
||||||
|
url_no_scheme = url[8:]
|
||||||
|
|
||||||
|
# Split by fragment (#tag)
|
||||||
|
if '#' in url_no_scheme:
|
||||||
|
url_part, tag = url_no_scheme.split('#', 1)
|
||||||
|
tag = unquote(tag)
|
||||||
|
else:
|
||||||
|
url_part = url_no_scheme
|
||||||
|
tag = "reality"
|
||||||
|
|
||||||
|
# Split by query (?)
|
||||||
|
if '?' in url_part:
|
||||||
|
uuid_host_port, query_string = url_part.split('?', 1)
|
||||||
|
else:
|
||||||
|
raise ValueError("Missing query parameters")
|
||||||
|
|
||||||
|
# Parse UUID@host:port
|
||||||
|
if '@' not in uuid_host_port:
|
||||||
|
raise ValueError("Missing @ separator")
|
||||||
|
|
||||||
|
uuid_str, host_port = uuid_host_port.split('@', 1)
|
||||||
|
|
||||||
|
if ':' not in host_port:
|
||||||
|
raise ValueError("Missing port")
|
||||||
|
|
||||||
|
host, port_str = host_port.rsplit(':', 1)
|
||||||
|
port = int(port_str)
|
||||||
|
|
||||||
|
# Parse query parameters
|
||||||
|
params = {}
|
||||||
|
for param in query_string.split('&'):
|
||||||
|
if '=' in param:
|
||||||
|
key, value = param.split('=', 1)
|
||||||
|
params[key] = unquote(value)
|
||||||
|
|
||||||
|
# Extract required parameters
|
||||||
|
pbk = params.get('pbk', '')
|
||||||
|
sid = params.get('sid', '')
|
||||||
|
sni = params.get('sni', host)
|
||||||
|
fp = params.get('fp', 'chrome')
|
||||||
|
flow = params.get('flow', '')
|
||||||
|
|
||||||
|
if not pbk or not sid:
|
||||||
|
raise ValueError("Missing required parameters: pbk or sid")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'uuid': uuid_str,
|
||||||
|
'server': host,
|
||||||
|
'server_port': port,
|
||||||
|
'tag': tag,
|
||||||
|
'public_key': pbk,
|
||||||
|
'short_id': sid,
|
||||||
|
'server_name': sni,
|
||||||
|
'fingerprint': fp,
|
||||||
|
'flow': flow
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_vless_config(vless_params: dict) -> dict:
|
||||||
|
"""Generate sing-box configuration from VLESS parameters"""
|
||||||
|
config = {
|
||||||
|
"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": vless_params['tag'],
|
||||||
|
"server": vless_params['server'],
|
||||||
|
"server_port": vless_params['server_port'],
|
||||||
|
"uuid": vless_params['uuid'],
|
||||||
|
"flow": vless_params['flow'],
|
||||||
|
"tls": {
|
||||||
|
"enabled": True,
|
||||||
|
"server_name": vless_params['server_name'],
|
||||||
|
"utls": {
|
||||||
|
"enabled": True,
|
||||||
|
"fingerprint": vless_params['fingerprint']
|
||||||
|
},
|
||||||
|
"reality": {
|
||||||
|
"enabled": True,
|
||||||
|
"public_key": vless_params['public_key'],
|
||||||
|
"short_id": vless_params['short_id']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packet_encoding": "xudp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"tag": "block"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"final": vless_params['tag'],
|
||||||
|
"auto_detect_interface": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
||||||
@@ -48,6 +222,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.serve_index()
|
self.serve_index()
|
||||||
elif self.path == "/status":
|
elif self.path == "/status":
|
||||||
self.get_status()
|
self.get_status()
|
||||||
|
elif self.path == "/subscription":
|
||||||
|
self.get_subscription()
|
||||||
elif self.path.startswith("/static/"):
|
elif self.path.startswith("/static/"):
|
||||||
self.serve_static()
|
self.serve_static()
|
||||||
else:
|
else:
|
||||||
@@ -57,6 +233,10 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
"""Handle POST requests"""
|
"""Handle POST requests"""
|
||||||
if self.path == "/apply":
|
if self.path == "/apply":
|
||||||
self.apply_config()
|
self.apply_config()
|
||||||
|
elif self.path == "/fetch-subscription":
|
||||||
|
self.fetch_subscription()
|
||||||
|
elif self.path == "/apply-subscription":
|
||||||
|
self.apply_subscription()
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
@@ -103,8 +283,20 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
"server": current_server
|
"server": current_server
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def get_subscription(self):
|
||||||
|
"""Get saved subscription info"""
|
||||||
|
sub = load_subscription()
|
||||||
|
if sub:
|
||||||
|
self.send_json({
|
||||||
|
"saved": True,
|
||||||
|
"url": sub.get("url"),
|
||||||
|
"selectedServer": sub.get("selectedServer")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self.send_json({"saved": False})
|
||||||
|
|
||||||
def apply_config(self):
|
def apply_config(self):
|
||||||
"""Apply new config from URL"""
|
"""Apply new config from VLESS URL"""
|
||||||
try:
|
try:
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
body = self.rfile.read(content_length).decode("utf-8")
|
body = self.rfile.read(content_length).decode("utf-8")
|
||||||
@@ -119,39 +311,173 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.send_json({"success": False, "error": "Неверный формат. Поддерживаются только vless:// ссылки"}, 400)
|
self.send_json({"success": False, "error": "Неверный формат. Поддерживаются только vless:// ссылки"}, 400)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Run gen-client-from-url.sh
|
# Parse VLESS URL
|
||||||
script_path = BASE_DIR / "gen-client-from-url.sh"
|
try:
|
||||||
result = subprocess.run(
|
vless_params = parse_vless_url(url)
|
||||||
[str(script_path), url, str(CONFIG_FILE)],
|
except ValueError as e:
|
||||||
capture_output=True,
|
self.send_json({"success": False, "error": f"Ошибка парсинга URL: {str(e)}"}, 400)
|
||||||
text=True,
|
|
||||||
cwd=str(BASE_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
|
return
|
||||||
|
|
||||||
|
# Generate config
|
||||||
|
config = generate_vless_config(vless_params)
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write config file
|
||||||
|
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
# Trigger reload via internal control port
|
# Trigger reload via internal control port
|
||||||
try:
|
try:
|
||||||
import urllib.request
|
|
||||||
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||||
# Continue anyway, config is generated
|
|
||||||
|
|
||||||
self.send_json({
|
self.send_json({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Конфигурация применена успешно!",
|
"message": f"Конфигурация '{vless_params['tag']}' успешно применена!"
|
||||||
"output": result.stdout
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({"success": False, "error": str(e)}, 500)
|
||||||
|
|
||||||
|
def fetch_subscription(self):
|
||||||
|
"""Fetch servers list from subscription 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
|
||||||
|
|
||||||
|
# Fetch subscription config
|
||||||
|
sys_info = get_system_info()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "singbox",
|
||||||
|
"x-hwid": get_hwid(),
|
||||||
|
"x-device-os": sys_info["os"],
|
||||||
|
"x-ver-os": sys_info["version"],
|
||||||
|
"x-device-model": APP_NAME
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as response:
|
||||||
|
config_text = response.read().decode("utf-8")
|
||||||
|
config = json.loads(config_text)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
self.send_json({"success": False, "error": f"Ошибка HTTP: {e.code}"}, 400)
|
||||||
|
return
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
self.send_json({"success": False, "error": f"Ошибка подключения: {e.reason}"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract outbound servers
|
||||||
|
outbounds = config.get("outbounds", [])
|
||||||
|
servers = []
|
||||||
|
|
||||||
|
for outbound in outbounds:
|
||||||
|
if outbound.get("type") in ["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]:
|
||||||
|
servers.append({
|
||||||
|
"tag": outbound.get("tag", "unknown"),
|
||||||
|
"type": outbound.get("type"),
|
||||||
|
"server": outbound.get("server", "unknown"),
|
||||||
|
"port": outbound.get("server_port", 443)
|
||||||
|
})
|
||||||
|
|
||||||
|
if not servers:
|
||||||
|
self.send_json({"success": False, "error": "Серверы не найдены в подписке"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json({
|
||||||
|
"success": True,
|
||||||
|
"servers": servers,
|
||||||
|
"config": config
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json({"success": False, "error": "Неверный JSON в ответе"}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({"success": False, "error": str(e)}, 500)
|
||||||
|
|
||||||
|
def apply_subscription(self):
|
||||||
|
"""Apply config from subscription with selected server"""
|
||||||
|
try:
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(content_length).decode("utf-8")
|
||||||
|
data = json.loads(body)
|
||||||
|
|
||||||
|
config = data.get("config")
|
||||||
|
selected_tag = data.get("selectedServer")
|
||||||
|
sub_url = data.get("subUrl") # URL подписки для сохранения
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
self.send_json({"success": False, "error": "Конфигурация не указана"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not selected_tag:
|
||||||
|
self.send_json({"success": False, "error": "Сервер не выбран"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Modify config to use only selected server
|
||||||
|
outbounds = config.get("outbounds", [])
|
||||||
|
new_outbounds = []
|
||||||
|
selected_outbound = None
|
||||||
|
|
||||||
|
for outbound in outbounds:
|
||||||
|
if outbound.get("tag") == selected_tag:
|
||||||
|
selected_outbound = outbound
|
||||||
|
elif outbound.get("type") in ["direct", "block", "dns"]:
|
||||||
|
new_outbounds.append(outbound)
|
||||||
|
elif outbound.get("type") == "selector":
|
||||||
|
# Skip selector, we'll add selected server directly
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not selected_outbound:
|
||||||
|
self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add selected server as main outbound
|
||||||
|
new_outbounds.insert(0, selected_outbound)
|
||||||
|
|
||||||
|
# Update route rules to use selected server
|
||||||
|
routes = config.get("route", {})
|
||||||
|
final_outbound = selected_tag
|
||||||
|
routes["final"] = final_outbound
|
||||||
|
|
||||||
|
config["outbounds"] = new_outbounds
|
||||||
|
config["route"] = routes
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write config file
|
||||||
|
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
# Save subscription URL for persistence
|
||||||
|
if sub_url:
|
||||||
|
save_subscription(sub_url, selected_tag)
|
||||||
|
|
||||||
|
# Trigger reload via internal control port
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||||
|
|
||||||
|
self.send_json({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Сервер '{selected_tag}' успешно применён!"
|
||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.send_json({"success": False, "error": "Таймаут при генерации конфига"}, 500)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({"success": False, "error": str(e)}, 500)
|
self.send_json({"success": False, "error": str(e)}, 500)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user