feat: Добавлена веб-панель управления VPN-прокси и Docker-конфигурация.

This commit is contained in:
2025-12-27 20:01:38 +03:00
parent 6a9d454d2a
commit b65b48d82b
8 changed files with 785 additions and 374 deletions

View File

@@ -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 минут
```
--- ---
## 📚 Словарь терминов ## 📚 Словарь терминов

View File

@@ -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
}
}

View File

@@ -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"]

View File

@@ -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..."

View File

@@ -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)"

View File

@@ -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."

View File

@@ -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);

View File

@@ -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)