mirror of
https://github.com/andrey271192/PCA_Phobos.git
synced 2026-09-20 11:55:32 +00:00
feat: PCA Phobos — web panel for obfuscated WireGuard VPN
Flask web panel for Phobos (wg-obfuscator + WireGuard): - Active sessions monitoring (wg show) - Client management (add/remove via phobos-client.sh) - Labels by Real IP - Subscription expiry with auto-kick - Telegram notifications (connect/disconnect/expiry) - One-command installer - Dark theme UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
125
README.md
Normal file
125
README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# PCA Phobos — Web Panel
|
||||
|
||||
Веб-панель управления для [Phobos](https://git.zerrolabs.org/Ground-Zerro/Phobos) (обфусцированный WireGuard VPN).
|
||||
|
||||
> Где поддержать: [Boosty (донат)](https://boosty.to/andrey27/donate) · [Ozon СБП](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) · [Telegram @lot_andrey](https://t.me/lot_andrey) · [**GitHub** ↗](https://github.com/andrey271192/PCA_Phobos)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
**Требование:** Phobos уже установлен на VPS ([инструкция](https://git.zerrolabs.org/Ground-Zerro/Phobos)).
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||
```
|
||||
|
||||
### С кастомными параметрами
|
||||
|
||||
```bash
|
||||
PANEL_PASS=AdminPass456 \
|
||||
TG_TOKEN=1234567890:AABBCCDDaabbccdd \
|
||||
TG_CHAT=123456789 \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||
```
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|--------------|----------------|---------------------------------|
|
||||
| `PANEL_PASS` | `OcAdmin2026!` | Пароль веб-панели (admin) |
|
||||
| `TG_TOKEN` | пусто | Telegram bot token |
|
||||
| `TG_CHAT` | пусто | Telegram chat ID для уведомлений|
|
||||
| `PANEL_PORT` | `8443` | Порт веб-панели |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Активные сессии** — VPN IP, Real IP, handshake, трафик RX/TX, Kick
|
||||
- **Клиенты VPN** — добавить/удалить через Phobos, статус online/offline
|
||||
- **Именование объектов** — привязать имя к Real IP (отображается в сессиях и Telegram)
|
||||
- **Срок подписки** — дата окончания для каждого клиента:
|
||||
- Date picker в таблице клиентов
|
||||
- Обратный отсчёт (18д, 3д⚠️, завтра⚠️, истёк⛔)
|
||||
- При истечении: автокик + Telegram уведомление
|
||||
- Предупреждения за 3 дня и 1 день
|
||||
- **Telegram уведомления** — 🟢 подключение, 🔴 отключение, ⚠️ за 3 дня, ⛔ истёк
|
||||
- **Настройки** — смена пароля панели, Telegram bot token + chat ID, интервал мониторинга
|
||||
- **Инфо о сервере** — порты, пути, команда установки на роутер
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Keenetic Router → wg-obfuscator (client) → :51821 → wg-obfuscator (server) → :51820 WireGuard → Internet
|
||||
│
|
||||
10.25.0.x
|
||||
│
|
||||
Web Panel :8443
|
||||
(Flask + Gunicorn)
|
||||
```
|
||||
|
||||
- Протокол: WireGuard + wg-obfuscator (обфускация от DPI)
|
||||
- Аутентификация: ключевые пары (нет паролей — только WireGuard ключи)
|
||||
- Подсеть VPN: `10.25.0.0/16`
|
||||
- Мониторинг сессий: каждые 30 сек (настраивается)
|
||||
|
||||
---
|
||||
|
||||
## Управление
|
||||
|
||||
```bash
|
||||
# Phobos (VPN)
|
||||
phobos # Интерактивное меню
|
||||
systemctl status wg-quick@wg0 # WireGuard
|
||||
systemctl status wg-obfuscator # Обфускатор
|
||||
wg show wg0 # Активные peers
|
||||
|
||||
# Веб-панель
|
||||
systemctl status phobos-panel
|
||||
systemctl restart phobos-panel
|
||||
journalctl -u phobos-panel -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
/opt/Phobos/
|
||||
├── clients/ # Клиенты VPN (ключи, конфиги)
|
||||
│ └── {name}/
|
||||
│ ├── metadata.json
|
||||
│ ├── {name}.conf
|
||||
│ └── wg-obfuscator.conf
|
||||
├── server/
|
||||
│ ├── server.env # Конфигурация сервера
|
||||
│ └── wg-obfuscator.conf
|
||||
└── repo/server/scripts/
|
||||
└── phobos-client.sh # Управление клиентами
|
||||
|
||||
/opt/phobos-panel/
|
||||
├── app.py # Flask веб-панель
|
||||
├── settings.json # Настройки (пароль, Telegram, метки, сроки)
|
||||
└── .secret_key # Ключ сессии
|
||||
|
||||
/etc/wireguard/
|
||||
└── wg0.conf # WireGuard конфигурация
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обновление панели
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/app.py \
|
||||
> /opt/phobos-panel/app.py
|
||||
systemctl restart phobos-panel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Поддержка проекта
|
||||
|
||||
- ⭐ **GitHub:** [andrey271192/PCA_Phobos](https://github.com/andrey271192/PCA_Phobos)
|
||||
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
|
||||
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
|
||||
- ✉️ **Telegram:** [@lot_andrey](https://t.me/lot_andrey)
|
||||
712
app.py
Normal file
712
app.py
Normal file
@@ -0,0 +1,712 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PCA Phobos — Web Panel for Phobos (Obfuscated WireGuard VPN)
|
||||
Management panel: clients, sessions, labels, subscriptions, Telegram alerts.
|
||||
"""
|
||||
|
||||
import json, os, subprocess, threading, time, secrets, hashlib, re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from flask import Flask, request, redirect, url_for, session, make_response
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
PHOBOS_DIR = "/opt/Phobos"
|
||||
CLIENTS_DIR = f"{PHOBOS_DIR}/clients"
|
||||
SERVER_ENV = f"{PHOBOS_DIR}/server/server.env"
|
||||
PANEL_DIR = "/opt/phobos-panel"
|
||||
SETTINGS_FILE = f"{PANEL_DIR}/settings.json"
|
||||
SECRET_FILE = f"{PANEL_DIR}/.secret_key"
|
||||
SERVER_IP = subprocess.getoutput("curl -s https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'").strip()
|
||||
|
||||
os.makedirs(PANEL_DIR, exist_ok=True)
|
||||
|
||||
if os.path.exists(SECRET_FILE):
|
||||
app.secret_key = open(SECRET_FILE).read().strip()
|
||||
else:
|
||||
app.secret_key = secrets.token_hex(32)
|
||||
with open(SECRET_FILE, "w") as f:
|
||||
f.write(app.secret_key)
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"admin_pass": "OcAdmin2026!",
|
||||
"tg_bot_token": "",
|
||||
"tg_chat_id": "",
|
||||
"monitor_interval": 30,
|
||||
"labels": {},
|
||||
"subscriptions": {}
|
||||
}
|
||||
|
||||
|
||||
def load_settings():
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
with open(SETTINGS_FILE) as f:
|
||||
s = json.load(f)
|
||||
for k, v in DEFAULT_SETTINGS.items():
|
||||
s.setdefault(k, v)
|
||||
return s
|
||||
return dict(DEFAULT_SETTINGS)
|
||||
|
||||
|
||||
def save_settings(s):
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(s, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def tg_send(text):
|
||||
s = load_settings()
|
||||
token, chat = s.get("tg_bot_token", ""), s.get("tg_chat_id", "")
|
||||
if not token or not chat:
|
||||
return
|
||||
try:
|
||||
import urllib.request
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
data = json.dumps({"chat_id": chat, "text": text, "parse_mode": "HTML"}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_wg_peers():
|
||||
"""Parse `wg show wg0` to get active peers with transfer/handshake info."""
|
||||
try:
|
||||
out = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
peers = {}
|
||||
current_pub = None
|
||||
for line in out.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("peer:"):
|
||||
current_pub = line.split("peer:")[1].strip()
|
||||
peers[current_pub] = {}
|
||||
elif current_pub and ":" in line:
|
||||
key, val = line.split(":", 1)
|
||||
peers[current_pub][key.strip()] = val.strip()
|
||||
return peers
|
||||
|
||||
|
||||
def get_clients():
|
||||
"""Read all clients from /opt/Phobos/clients/*/metadata.json."""
|
||||
clients = []
|
||||
clients_path = Path(CLIENTS_DIR)
|
||||
if not clients_path.exists():
|
||||
return clients
|
||||
for d in sorted(clients_path.iterdir()):
|
||||
meta_file = d / "metadata.json"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
with open(meta_file) as f:
|
||||
meta = json.load(f)
|
||||
meta["_dir"] = str(d)
|
||||
clients.append(meta)
|
||||
except Exception:
|
||||
pass
|
||||
return clients
|
||||
|
||||
|
||||
def get_active_sessions():
|
||||
"""Combine WG peers with client metadata to build session list."""
|
||||
peers = get_wg_peers()
|
||||
clients = get_clients()
|
||||
|
||||
pub_to_client = {}
|
||||
for c in clients:
|
||||
pub_to_client[c.get("public_key", "")] = c
|
||||
|
||||
sessions = []
|
||||
for pub_key, info in peers.items():
|
||||
handshake = info.get("latest handshake", "")
|
||||
if not handshake:
|
||||
continue
|
||||
|
||||
client = pub_to_client.get(pub_key, {})
|
||||
client_id = client.get("client_id", "unknown")
|
||||
tunnel_ip = client.get("tunnel_ip_v4", "")
|
||||
|
||||
endpoint = info.get("endpoint", "")
|
||||
real_ip = endpoint.split(":")[0] if endpoint else ""
|
||||
|
||||
rx = info.get("transfer", "")
|
||||
rx_bytes = rx.split("received,")[0].strip() if "received," in rx else ""
|
||||
tx_bytes = rx.split("received,")[1].strip().replace("sent", "").strip() if "received," in rx else ""
|
||||
|
||||
sessions.append({
|
||||
"client_id": client_id,
|
||||
"public_key": pub_key,
|
||||
"tunnel_ip": tunnel_ip,
|
||||
"real_ip": real_ip,
|
||||
"endpoint": endpoint,
|
||||
"handshake": handshake,
|
||||
"rx": rx_bytes,
|
||||
"tx": tx_bytes,
|
||||
})
|
||||
return sessions
|
||||
|
||||
|
||||
def is_peer_online(handshake_str):
|
||||
"""Check if peer had a handshake within last 3 minutes."""
|
||||
try:
|
||||
parts = handshake_str.split(",")
|
||||
total_seconds = 0
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if "minute" in p:
|
||||
total_seconds += int(re.search(r"(\d+)", p).group(1)) * 60
|
||||
elif "second" in p:
|
||||
total_seconds += int(re.search(r"(\d+)", p).group(1))
|
||||
elif "hour" in p:
|
||||
total_seconds += int(re.search(r"(\d+)", p).group(1)) * 3600
|
||||
return total_seconds < 180
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def kick_peer(public_key):
|
||||
"""Remove and re-add peer to force disconnect."""
|
||||
try:
|
||||
out = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5)
|
||||
allowed = ""
|
||||
found = False
|
||||
for line in out.split("\n"):
|
||||
if line.strip().startswith("peer:") and public_key in line:
|
||||
found = True
|
||||
elif found and "allowed ips:" in line:
|
||||
allowed = line.split("allowed ips:")[1].strip()
|
||||
break
|
||||
|
||||
subprocess.run(["wg", "set", "wg0", "peer", public_key, "remove"], timeout=5)
|
||||
if allowed:
|
||||
subprocess.run(["wg", "set", "wg0", "peer", public_key, "allowed-ips", allowed], timeout=5)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_expiry():
|
||||
"""Check subscription expiry, lock expired clients."""
|
||||
s = load_settings()
|
||||
subs = s.get("subscriptions", {})
|
||||
today = datetime.now().date()
|
||||
changed = False
|
||||
|
||||
for client_id, info in list(subs.items()):
|
||||
if not info.get("expiry"):
|
||||
continue
|
||||
try:
|
||||
exp_date = datetime.strptime(info["expiry"], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
days_left = (exp_date - today).days
|
||||
|
||||
if days_left <= 0 and not info.get("locked"):
|
||||
info["locked"] = True
|
||||
changed = True
|
||||
kick_client_by_id(client_id)
|
||||
tg_send(f"⛔ <b>{client_id}</b> — подписка истекла! Клиент заблокирован.")
|
||||
elif days_left == 3 and not info.get("warn3"):
|
||||
info["warn3"] = True
|
||||
changed = True
|
||||
tg_send(f"⚠️ <b>{client_id}</b> — подписка истекает через 3 дня ({info['expiry']})")
|
||||
elif days_left == 1 and not info.get("warn1"):
|
||||
info["warn1"] = True
|
||||
changed = True
|
||||
tg_send(f"⚠️ <b>{client_id}</b> — подписка истекает ЗАВТРА ({info['expiry']})")
|
||||
|
||||
if changed:
|
||||
save_settings(s)
|
||||
|
||||
|
||||
def kick_client_by_id(client_id):
|
||||
"""Find client's public key and kick them."""
|
||||
clients = get_clients()
|
||||
for c in clients:
|
||||
if c.get("client_id") == client_id:
|
||||
kick_peer(c.get("public_key", ""))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
prev_session_keys = None
|
||||
|
||||
|
||||
def session_monitor():
|
||||
"""Background thread: monitor sessions, send Telegram alerts."""
|
||||
global prev_session_keys
|
||||
while True:
|
||||
try:
|
||||
s = load_settings()
|
||||
interval = s.get("monitor_interval", 30)
|
||||
|
||||
sessions = get_active_sessions()
|
||||
current_keys = set()
|
||||
for sess in sessions:
|
||||
if is_peer_online(sess.get("handshake", "")):
|
||||
key = (sess["client_id"], sess["real_ip"])
|
||||
current_keys.add(key)
|
||||
|
||||
if prev_session_keys is not None:
|
||||
labels = s.get("labels", {})
|
||||
for key in current_keys - prev_session_keys:
|
||||
client_id, real_ip = key
|
||||
label = labels.get(real_ip, "")
|
||||
name = f"{label} ({real_ip})" if label else real_ip
|
||||
tg_send(f"🟢 <b>{client_id}</b> подключился — {name}")
|
||||
|
||||
for key in prev_session_keys - current_keys:
|
||||
client_id, real_ip = key
|
||||
label = labels.get(real_ip, "")
|
||||
name = f"{label} ({real_ip})" if label else real_ip
|
||||
tg_send(f"🔴 <b>{client_id}</b> отключился — {name}")
|
||||
|
||||
prev_session_keys = current_keys
|
||||
|
||||
check_expiry()
|
||||
|
||||
time.sleep(interval)
|
||||
except Exception:
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
monitor_thread = threading.Thread(target=session_monitor, daemon=True)
|
||||
monitor_thread.start()
|
||||
|
||||
|
||||
PAGE = """<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Phobos VPN Panel</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
|
||||
.header{background:linear-gradient(135deg,#1e1b4b,#312e81);padding:20px;text-align:center;border-bottom:2px solid #4f46e5}
|
||||
.header h1{font-size:1.6em;color:#a5b4fc}
|
||||
.header .subtitle{font-size:.85em;color:#818cf8;margin-top:4px}
|
||||
.container{max-width:1000px;margin:20px auto;padding:0 16px}
|
||||
.card{background:#1e293b;border-radius:12px;padding:20px;margin-bottom:16px;border:1px solid #334155}
|
||||
.card h2{color:#a5b4fc;font-size:1.1em;margin-bottom:12px;border-bottom:1px solid #334155;padding-bottom:8px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;color:#94a3b8;font-size:.8em;padding:8px 6px;border-bottom:1px solid #334155}
|
||||
td{padding:8px 6px;border-bottom:1px solid #1e293b;font-size:.9em}
|
||||
tr:hover{background:#262f3d}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:.75em;font-weight:600}
|
||||
.badge-on{background:#065f46;color:#6ee7b7}
|
||||
.badge-off{background:#7f1d1d;color:#fca5a5}
|
||||
.badge-warn{background:#78350f;color:#fcd34d}
|
||||
.badge-lock{background:#581c87;color:#d8b4fe}
|
||||
.btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:.85em;color:#fff;text-decoration:none;display:inline-block}
|
||||
.btn-primary{background:#4f46e5}.btn-primary:hover{background:#4338ca}
|
||||
.btn-danger{background:#dc2626}.btn-danger:hover{background:#b91c1c}
|
||||
.btn-sm{padding:4px 10px;font-size:.78em}
|
||||
input,select{background:#0f172a;border:1px solid #334155;color:#e2e8f0;padding:8px 12px;border-radius:6px;font-size:.9em}
|
||||
input:focus{outline:none;border-color:#4f46e5}
|
||||
.form-row{display:flex;gap:10px;margin-bottom:10px;align-items:center;flex-wrap:wrap}
|
||||
.form-row label{min-width:120px;color:#94a3b8;font-size:.85em}
|
||||
.expiry-badge{font-size:.78em;padding:2px 6px;border-radius:6px}
|
||||
.nav{display:flex;gap:10px;justify-content:center;margin:16px 0}
|
||||
.nav a{color:#818cf8;text-decoration:none;padding:6px 16px;border-radius:6px;font-size:.9em}
|
||||
.nav a:hover,.nav a.active{background:#312e81;color:#a5b4fc}
|
||||
.footer{text-align:center;padding:30px 20px;color:#475569;font-size:.8em;border-top:1px solid #1e293b;margin-top:30px}
|
||||
.footer a{color:#6366f1;text-decoration:none}
|
||||
.footer a:hover{text-decoration:underline}
|
||||
.login-box{max-width:360px;margin:80px auto;padding:30px;background:#1e293b;border-radius:12px;border:1px solid #334155}
|
||||
.login-box h2{text-align:center;color:#a5b4fc;margin-bottom:20px}
|
||||
.login-box input{width:100%;margin-bottom:12px}
|
||||
.login-box .btn{width:100%}
|
||||
.alert{padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:.85em}
|
||||
.alert-error{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b}
|
||||
.alert-ok{background:#065f46;color:#6ee7b7;border:1px solid #047857}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🛡️ Phobos VPN Panel</h1>
|
||||
<div class="subtitle">Obfuscated WireGuard · """ + SERVER_IP + """</div>
|
||||
</div>
|
||||
CONTENT
|
||||
<div class="footer">
|
||||
<a href="https://github.com/andrey271192/PCA_Phobos" target="_blank">GitHub</a> ·
|
||||
<a href="https://boosty.to/andrey27/donate" target="_blank">Boosty</a> ·
|
||||
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank">Ozon СБП</a> ·
|
||||
<a href="https://t.me/lot_andrey" target="_blank">Telegram @lot_andrey</a>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def render(content):
|
||||
return PAGE.replace("CONTENT", content)
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
s = load_settings()
|
||||
msg = ""
|
||||
if request.method == "POST":
|
||||
if request.form.get("password") == s["admin_pass"]:
|
||||
session["auth"] = True
|
||||
return redirect(url_for("dashboard"))
|
||||
msg = '<div class="alert alert-error">Неверный пароль</div>'
|
||||
html = f"""
|
||||
<div class="login-box">
|
||||
<h2>Вход в панель</h2>
|
||||
{msg}
|
||||
<form method="post">
|
||||
<input type="password" name="password" placeholder="Пароль" autofocus>
|
||||
<button class="btn btn-primary" type="submit">Войти</button>
|
||||
</form>
|
||||
</div>"""
|
||||
return render(html)
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
def auth_required(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("auth"):
|
||||
return redirect(url_for("login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@auth_required
|
||||
def dashboard():
|
||||
return redirect(url_for("sessions_page"))
|
||||
|
||||
|
||||
@app.route("/sessions")
|
||||
@auth_required
|
||||
def sessions_page():
|
||||
s = load_settings()
|
||||
labels = s.get("labels", {})
|
||||
sessions = get_active_sessions()
|
||||
|
||||
rows = ""
|
||||
online_count = 0
|
||||
for sess in sessions:
|
||||
online = is_peer_online(sess.get("handshake", ""))
|
||||
if online:
|
||||
online_count += 1
|
||||
status = '<span class="badge badge-on">Online</span>' if online else '<span class="badge badge-off">Offline</span>'
|
||||
real_ip = sess["real_ip"]
|
||||
label = labels.get(real_ip, "")
|
||||
label_display = f"<b>{label}</b> " if label else ""
|
||||
|
||||
rows += f"""<tr>
|
||||
<td>{sess['client_id']}</td>
|
||||
<td>{sess['tunnel_ip']}</td>
|
||||
<td>{label_display}{real_ip}</td>
|
||||
<td>{sess['handshake']}</td>
|
||||
<td>{sess['rx']} / {sess['tx']}</td>
|
||||
<td>{status}</td>
|
||||
<td><a href="/kick/{sess['public_key']}" class="btn btn-danger btn-sm">Kick</a></td>
|
||||
</tr>"""
|
||||
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="7" style="text-align:center;color:#64748b">Нет активных сессий</td></tr>'
|
||||
|
||||
html = f"""
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/sessions" class="active">Сессии ({online_count})</a>
|
||||
<a href="/clients">Клиенты</a>
|
||||
<a href="/labels">Метки</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<a href="/logout">Выход</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Активные сессии</h2>
|
||||
<table>
|
||||
<tr><th>Клиент</th><th>VPN IP</th><th>Real IP</th><th>Handshake</th><th>RX / TX</th><th>Статус</th><th></th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
</div>
|
||||
</div>"""
|
||||
return render(html)
|
||||
|
||||
|
||||
@app.route("/kick/<path:pub_key>")
|
||||
@auth_required
|
||||
def kick(pub_key):
|
||||
kick_peer(pub_key)
|
||||
return redirect(url_for("sessions_page"))
|
||||
|
||||
|
||||
@app.route("/clients", methods=["GET", "POST"])
|
||||
@auth_required
|
||||
def clients_page():
|
||||
s = load_settings()
|
||||
subs = s.get("subscriptions", {})
|
||||
msg = ""
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
if action == "add":
|
||||
name = request.form.get("name", "").strip()
|
||||
if name and re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[f"{PHOBOS_DIR}/repo/server/scripts/phobos-client.sh", "add", name],
|
||||
text=True, timeout=30, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, **_load_server_env()}
|
||||
)
|
||||
msg = f'<div class="alert alert-ok">Клиент {name} создан</div>'
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f'<div class="alert alert-error">{e.output}</div>'
|
||||
else:
|
||||
msg = '<div class="alert alert-error">Имя: буквы, цифры, _ и -</div>'
|
||||
|
||||
elif action == "delete":
|
||||
client_id = request.form.get("client_id", "").strip()
|
||||
if client_id:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[f"{PHOBOS_DIR}/repo/server/scripts/phobos-client.sh", "remove", client_id],
|
||||
text=True, timeout=15, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, **_load_server_env()}
|
||||
)
|
||||
msg = f'<div class="alert alert-ok">Клиент {client_id} удалён</div>'
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f'<div class="alert alert-error">{e.output}</div>'
|
||||
|
||||
elif action == "set_expiry":
|
||||
client_id = request.form.get("client_id", "").strip()
|
||||
expiry = request.form.get("expiry", "").strip()
|
||||
if client_id:
|
||||
if client_id not in subs:
|
||||
subs[client_id] = {}
|
||||
subs[client_id]["expiry"] = expiry
|
||||
subs[client_id].pop("locked", None)
|
||||
subs[client_id].pop("warn3", None)
|
||||
subs[client_id].pop("warn1", None)
|
||||
save_settings(s)
|
||||
msg = f'<div class="alert alert-ok">Срок для {client_id} обновлён</div>'
|
||||
|
||||
clients = get_clients()
|
||||
peers = get_wg_peers()
|
||||
online_pubs = set()
|
||||
for pub, info in peers.items():
|
||||
if is_peer_online(info.get("latest handshake", "")):
|
||||
online_pubs.add(pub)
|
||||
|
||||
rows = ""
|
||||
today = datetime.now().date()
|
||||
for c in clients:
|
||||
cid = c.get("client_id", "")
|
||||
pub = c.get("public_key", "")
|
||||
ip = c.get("tunnel_ip_v4", "")
|
||||
created = c.get("created_at", "")[:10]
|
||||
is_online = pub in online_pubs
|
||||
status = '<span class="badge badge-on">Online</span>' if is_online else '<span class="badge badge-off">Offline</span>'
|
||||
|
||||
sub = subs.get(cid, {})
|
||||
expiry = sub.get("expiry", "")
|
||||
locked = sub.get("locked", False)
|
||||
expiry_badge = ""
|
||||
if expiry:
|
||||
try:
|
||||
exp_date = datetime.strptime(expiry, "%Y-%m-%d").date()
|
||||
days = (exp_date - today).days
|
||||
if locked:
|
||||
expiry_badge = f'<span class="expiry-badge badge-lock">⛔ истёк</span>'
|
||||
elif days <= 1:
|
||||
expiry_badge = f'<span class="expiry-badge badge-warn">⚠️ {days}д</span>'
|
||||
elif days <= 3:
|
||||
expiry_badge = f'<span class="expiry-badge badge-warn">{days}д</span>'
|
||||
else:
|
||||
expiry_badge = f'<span class="expiry-badge badge-on">{days}д</span>'
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rows += f"""<tr>
|
||||
<td>{cid}</td>
|
||||
<td>{ip}</td>
|
||||
<td>{created}</td>
|
||||
<td>{status}</td>
|
||||
<td>
|
||||
<form method="post" style="display:flex;gap:4px;align-items:center">
|
||||
<input type="hidden" name="action" value="set_expiry">
|
||||
<input type="hidden" name="client_id" value="{cid}">
|
||||
<input type="date" name="expiry" value="{expiry}" style="width:140px;padding:4px">
|
||||
<button class="btn btn-primary btn-sm" type="submit">✓</button>
|
||||
{expiry_badge}
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Удалить {cid}?')">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="client_id" value="{cid}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/sessions">Сессии</a>
|
||||
<a href="/clients" class="active">Клиенты</a>
|
||||
<a href="/labels">Метки</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<a href="/logout">Выход</a>
|
||||
</div>
|
||||
{msg}
|
||||
<div class="card">
|
||||
<h2>Клиенты VPN</h2>
|
||||
<form method="post" class="form-row" style="margin-bottom:16px">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="text" name="name" placeholder="Имя нового клиента" pattern="[a-zA-Z0-9_-]+" required>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
</form>
|
||||
<table>
|
||||
<tr><th>Клиент</th><th>VPN IP</th><th>Создан</th><th>Статус</th><th>Подписка</th><th></th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
</div>
|
||||
</div>"""
|
||||
return render(html)
|
||||
|
||||
|
||||
@app.route("/labels", methods=["GET", "POST"])
|
||||
@auth_required
|
||||
def labels_page():
|
||||
s = load_settings()
|
||||
msg = ""
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
if action == "add":
|
||||
ip = request.form.get("ip", "").strip()
|
||||
label = request.form.get("label", "").strip()
|
||||
if ip and label:
|
||||
s["labels"][ip] = label
|
||||
save_settings(s)
|
||||
msg = f'<div class="alert alert-ok">Метка добавлена: {ip} → {label}</div>'
|
||||
elif action == "delete":
|
||||
ip = request.form.get("ip", "").strip()
|
||||
s["labels"].pop(ip, None)
|
||||
save_settings(s)
|
||||
msg = f'<div class="alert alert-ok">Метка удалена</div>'
|
||||
|
||||
labels = s.get("labels", {})
|
||||
rows = ""
|
||||
for ip, label in sorted(labels.items()):
|
||||
rows += f"""<tr>
|
||||
<td>{ip}</td><td>{label}</td>
|
||||
<td><form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="ip" value="{ip}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">✕</button>
|
||||
</form></td></tr>"""
|
||||
|
||||
html = f"""
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/sessions">Сессии</a>
|
||||
<a href="/clients">Клиенты</a>
|
||||
<a href="/labels" class="active">Метки</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<a href="/logout">Выход</a>
|
||||
</div>
|
||||
{msg}
|
||||
<div class="card">
|
||||
<h2>Метки по Real IP</h2>
|
||||
<form method="post" class="form-row" style="margin-bottom:16px">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="text" name="ip" placeholder="Real IP" required>
|
||||
<input type="text" name="label" placeholder="Имя объекта" required>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
</form>
|
||||
<table>
|
||||
<tr><th>Real IP</th><th>Метка</th><th></th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
</div>
|
||||
</div>"""
|
||||
return render(html)
|
||||
|
||||
|
||||
@app.route("/settings", methods=["GET", "POST"])
|
||||
@auth_required
|
||||
def settings_page():
|
||||
s = load_settings()
|
||||
msg = ""
|
||||
if request.method == "POST":
|
||||
new_pass = request.form.get("admin_pass", "").strip()
|
||||
if new_pass:
|
||||
s["admin_pass"] = new_pass
|
||||
s["tg_bot_token"] = request.form.get("tg_bot_token", "").strip()
|
||||
s["tg_chat_id"] = request.form.get("tg_chat_id", "").strip()
|
||||
try:
|
||||
s["monitor_interval"] = max(10, int(request.form.get("monitor_interval", 30)))
|
||||
except ValueError:
|
||||
pass
|
||||
save_settings(s)
|
||||
msg = '<div class="alert alert-ok">Настройки сохранены</div>'
|
||||
|
||||
html = f"""
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/sessions">Сессии</a>
|
||||
<a href="/clients">Клиенты</a>
|
||||
<a href="/labels">Метки</a>
|
||||
<a href="/settings" class="active">Настройки</a>
|
||||
<a href="/logout">Выход</a>
|
||||
</div>
|
||||
{msg}
|
||||
<div class="card">
|
||||
<h2>Настройки панели</h2>
|
||||
<form method="post">
|
||||
<div class="form-row"><label>Пароль панели</label><input type="password" name="admin_pass" placeholder="Оставьте пустым"></div>
|
||||
<div class="form-row"><label>Telegram Token</label><input type="text" name="tg_bot_token" value="{s.get('tg_bot_token','')}"></div>
|
||||
<div class="form-row"><label>Telegram Chat ID</label><input type="text" name="tg_chat_id" value="{s.get('tg_chat_id','')}"></div>
|
||||
<div class="form-row"><label>Интервал (сек)</label><input type="number" name="monitor_interval" value="{s.get('monitor_interval',30)}" min="10"></div>
|
||||
<div class="form-row"><label></label><button class="btn btn-primary" type="submit">Сохранить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Информация о сервере</h2>
|
||||
<table>
|
||||
<tr><td>VPS IP</td><td>{SERVER_IP}</td></tr>
|
||||
<tr><td>WireGuard порт</td><td>51820 (localhost)</td></tr>
|
||||
<tr><td>Обфускатор порт</td><td>51821</td></tr>
|
||||
<tr><td>Панель порт</td><td>8443</td></tr>
|
||||
<tr><td>Phobos клиенты</td><td>{CLIENTS_DIR}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Установка на роутер</h2>
|
||||
<p style="color:#94a3b8;font-size:.85em;margin-bottom:8px">Keenetic/Netcraze с Entware — выполнить по SSH на роутере:</p>
|
||||
<code style="background:#0f172a;padding:8px 12px;border-radius:6px;display:block;font-size:.85em;word-break:break-all" id="install-cmd">curl -s http://{SERVER_IP}/init/TOKEN.sh | sh</code>
|
||||
</div>
|
||||
</div>"""
|
||||
return render(html)
|
||||
|
||||
|
||||
def _load_server_env():
|
||||
"""Load server.env as dict for subprocess env."""
|
||||
env = {}
|
||||
if os.path.exists(SERVER_ENV):
|
||||
with open(SERVER_ENV) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v
|
||||
return env
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8443, debug=False)
|
||||
83
install.sh
Normal file
83
install.sh
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# PCA Phobos — Web Panel Installer
|
||||
# Requires: Phobos already installed (/opt/Phobos)
|
||||
#
|
||||
# Usage:
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/install.sh)
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
PANEL_PASS="${PANEL_PASS:-OcAdmin2026!}"
|
||||
TG_TOKEN="${TG_TOKEN:-}"
|
||||
TG_CHAT="${TG_CHAT:-}"
|
||||
PANEL_PORT="${PANEL_PORT:-8443}"
|
||||
PANEL_DIR="/opt/phobos-panel"
|
||||
|
||||
# ── Check Phobos is installed ──
|
||||
if [ ! -d "/opt/Phobos" ]; then
|
||||
echo "ERROR: Phobos not found at /opt/Phobos"
|
||||
echo "Install Phobos first: https://git.zerrolabs.org/Ground-Zerro/Phobos"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ PCA Phobos Panel Installer ║"
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
echo "║ Panel port : $PANEL_PORT"
|
||||
echo "║ Phobos dir : /opt/Phobos"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# ── 1. Install dependencies ──
|
||||
echo "[1/3] Installing dependencies..."
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-flask gunicorn
|
||||
|
||||
# ── 2. Install panel ──
|
||||
echo "[2/3] Installing web panel..."
|
||||
mkdir -p "$PANEL_DIR"
|
||||
|
||||
SERVER_IP=$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')
|
||||
|
||||
curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/app.py" \
|
||||
| sed "s|SERVER_IP = .*|SERVER_IP = \"$SERVER_IP\"|g" \
|
||||
> "$PANEL_DIR/app.py"
|
||||
|
||||
# Create initial settings
|
||||
if [ ! -f "$PANEL_DIR/settings.json" ]; then
|
||||
cat > "$PANEL_DIR/settings.json" <<EOF
|
||||
{
|
||||
"admin_pass": "$PANEL_PASS",
|
||||
"tg_bot_token": "$TG_TOKEN",
|
||||
"tg_chat_id": "$TG_CHAT",
|
||||
"monitor_interval": 30,
|
||||
"labels": {},
|
||||
"subscriptions": {}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── 3. Setup systemd service ──
|
||||
echo "[3/3] Setting up service..."
|
||||
|
||||
curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/phobos-panel.service" \
|
||||
> /etc/systemd/system/phobos-panel.service
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable phobos-panel -q
|
||||
systemctl restart phobos-panel
|
||||
sleep 2
|
||||
systemctl is-active --quiet phobos-panel && echo " Panel running." || { echo "ERROR: panel failed!"; journalctl -u phobos-panel -n 20; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ Installation Complete! ║"
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
echo "║ Web Panel : http://$SERVER_IP:$PANEL_PORT"
|
||||
echo "║ Admin login : admin"
|
||||
echo "║ Admin pass : $PANEL_PASS"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
15
phobos-panel.service
Normal file
15
phobos-panel.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Phobos VPN Web Panel
|
||||
After=network.target wg-quick@wg0.service
|
||||
Wants=wg-quick@wg0.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/phobos-panel
|
||||
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:8443 app:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user