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:
Андрей Бобырев
2026-05-28 23:06:08 +03:00
commit e11451b984
4 changed files with 935 additions and 0 deletions

125
README.md Normal file
View 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
View 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
View 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
View 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