Files
PCA_Phobos/app.py
Андрей Бобырев e11451b984 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>
2026-05-28 23:06:08 +03:00

713 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)