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