Files
Py-3XUI-multiserver/aggregator.py
T

1674 lines
71 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
"""
ZernProxy Manager v22 - Soft-Tariff with SQLite
"""
import json
import base64
import logging
import asyncio
import io
import os
import qrcode
import uuid
import hashlib
import secrets
import sqlite3
import time
from datetime import datetime, timedelta
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.moduledrawers import SquareModuleDrawer
from typing import List, Dict, Optional, Tuple
from contextlib import contextmanager
from fastapi import FastAPI, Response, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
import uvicorn
import httpx
import re
from py3xui import Api
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("zernproxy")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SERVERS_CONF = os.path.join(BASE_DIR, "servers.conf")
SETTINGS_CONF = os.path.join(BASE_DIR, "settings.conf")
USERS_DB = os.path.join(BASE_DIR, "users.db")
LOGO_PATH = os.path.join(BASE_DIR, "logo.png")
MOTD_PATH = os.path.join(BASE_DIR, "motd.txt")
servers = []
settings = {}
last_donation_id = 0
# In-memory cache
_links_cache: Dict[str, Tuple[List[str], float]] = {}
_traffic_cache: Dict[str, Tuple[Dict, float]] = {}
CACHE_TTL_TRAFFIC = 120
CACHE_TTL_LINKS = 86400 * 7
def _get_cached(key: str, cache: dict, ttl: float):
entry = cache.get(key)
if entry and time.time() - entry[1] < ttl:
return entry[0]
return None
def _set_cache(key: str, value, cache: dict):
cache[key] = (value, time.time())
def get_cached_links(sub_id: str):
return _get_cached(sub_id, _links_cache, CACHE_TTL_LINKS)
def set_cached_links(sub_id: str, links: List[str]):
_set_cache(sub_id, links, _links_cache)
def get_cached_traffic(sub_id: str):
return _get_cached(sub_id, _traffic_cache, CACHE_TTL_TRAFFIC)
def set_cached_traffic(sub_id: str, data: dict):
_set_cache(sub_id, data, _traffic_cache)
def clear_cache(sub_id: str = None):
if sub_id:
_links_cache.pop(sub_id, None)
_traffic_cache.pop(sub_id, None)
else:
_links_cache.clear()
_traffic_cache.clear()
def load_json(path: str, default: dict) -> dict:
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading {path}: {e}")
return default
def save_json(path: str, data: dict):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
except Exception as e:
logger.error(f"Error saving {path}: {e}")
def init_db():
conn = sqlite3.connect(USERS_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
subscription_id TEXT UNIQUE NOT NULL,
tier TEXT DEFAULT 'free',
tariff_days_bought INTEGER DEFAULT 0,
tariff_days_remaining INTEGER DEFAULT 0,
total_paid_rubles INTEGER DEFAULT 0,
traffic_limit_gb INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def get_db():
conn = sqlite3.connect(USERS_DB, timeout=30.0)
conn.row_factory = sqlite3.Row
return conn
def load_configs():
global servers, settings
servers = load_json(SERVERS_CONF, {"servers": []}).get("servers", [])
settings = load_json(SETTINGS_CONF, {
"general": {"title": "ZernProxy", "host": "conn.zernmc.ru", "support_url": ""},
"announcement": "",
"shortid_rotation_hours": 11,
"tiers": {
"free": {"name": "Free", "servers": [], "traffic_limit_gb": 0},
"paid": {"name": "Premium", "servers": [], "traffic_limit_gb": 0}
},
"payments": {"donationalerts": {"enabled": False, "api_token": "", "webhook_secret": "", "check_interval_minutes": 5}}
})
logger.info(f"Loaded {len(servers)} servers")
init_db()
load_configs()
def get_flag_emoji(country_code: str) -> str:
if not country_code or len(country_code) < 2:
return ""
try:
return chr(ord(country_code[0].upper()) + 127397) + chr(ord(country_code[1].upper()) + 127397)
except:
return ""
def generate_qr_base64(data: str, size: int = 300) -> str:
qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white", image_factory=StyledPilImage, module_drawer=SquareModuleDrawer())
img = img.resize((size, size), resample=0)
buffered = io.BytesIO()
img.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode()
def get_logo_base64() -> Optional[str]:
if os.path.exists(LOGO_PATH):
try:
with open(LOGO_PATH, "rb") as f:
return base64.b64encode(f.read()).decode()
except:
pass
return None
def get_motd() -> str:
if os.path.exists(MOTD_PATH):
try:
with open(MOTD_PATH, "r", encoding="utf-8") as f:
return f.read().strip()
except:
pass
return ""
def format_bytes(bytes_val: int) -> str:
if bytes_val == 0:
return "0 B"
sizes = ["B", "KB", "MB", "GB", "TB"]
i = 0
val = float(bytes_val)
while val >= 1024 and i < len(sizes) - 1:
val /= 1024
i += 1
return f"{val:.1f} {sizes[i]}" if val < 100 else f"{int(val)} {sizes[i]}"
def get_traffic_stats(sub_id: str) -> dict:
total_up = 0
total_down = 0
by_server = {}
for srv in servers:
srv_up = 0
srv_down = 0
for inbound in srv.get("inbounds", []):
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
inbound_id = inbound.get("id")
if not all([api_host, api_user, api_pass, inbound_id]):
continue
try:
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
api.login()
inbounds = api.inbound.get_list()
for ib in inbounds:
if ib.id == inbound_id and ib.client_stats:
for client in ib.client_stats:
if getattr(client, 'sub_id', '') == sub_id:
srv_up += client.up or 0
srv_down += client.down or 0
except:
pass
if srv_up or srv_down:
by_server[srv["name"]] = {"up": srv_up, "down": srv_down}
total_up += srv_up
total_down += srv_down
return {"total_up": total_up, "total_down": total_down, "by_server": by_server}
def generate_sub_id(length: int = 16) -> str:
return ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(length))
async def fetch_vless_links(url: str) -> List[str]:
async with httpx.AsyncClient(verify=False, timeout=10.0, follow_redirects=True) as client:
try:
resp = await client.get(url)
if resp.status_code == 404 or resp.status_code == 400:
logger.warning(f"Sub not found on server: {url}")
return []
if resp.status_code == 200:
content = resp.text.strip()
try:
decoded = base64.b64decode(content).decode('utf-8')
links = re.findall(r'(vless://[^\s\n]+)', decoded)
if links:
return links
except:
pass
return re.findall(r'(vless://[^\s\n]+)', content)
except Exception as e:
logger.error(f"Fetch error: {e}")
return []
def find_user_on_servers(sub_id: str) -> Optional[str]:
"""Ищет юзера на 3x-UI серверах по subId, возвращает username"""
logger.info(f"Looking for sub_id: {sub_id}")
for srv in servers:
for inbound in srv.get("inbounds", []):
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
inbound_id = inbound.get("id")
if not all([api_host, api_user, api_pass, inbound_id]):
continue
try:
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
api.login()
inbounds = api.inbound.get_list()
for ib in inbounds:
if ib.id == inbound_id and ib.client_stats:
for client in ib.client_stats:
client_subid = getattr(client, 'sub_id', '') or ''
logger.info(f"Found client: {client.email} sub_id: {client_subid}")
if client_subid == sub_id:
email = client.email or ""
if '@' in email:
username = email.split('@')[0]
if '_' in username:
username = username.split('_')[0]
logger.info(f"Migrated user: {username} (found on {srv['name']}/{inbound['name']})")
return username
except Exception as e:
logger.error(f"Error checking {srv['name']}/{inbound['name']}: {e}")
return None
def get_user_tier(sub_id: str) -> Tuple[Optional[dict], str]:
conn = get_db()
try:
user = conn.execute("SELECT * FROM users WHERE subscription_id = ?", (sub_id,)).fetchone()
if not user:
username = find_user_on_servers(sub_id)
if username:
try:
conn.execute("""
INSERT INTO users (username, subscription_id, tier, tariff_days_bought, tariff_days_remaining, total_paid_rubles, traffic_limit_gb, is_active)
VALUES (?, ?, 'free', 0, 0, 0, 0, 1)
""", (username, sub_id))
conn.commit()
user = conn.execute("SELECT * FROM users WHERE subscription_id = ?", (sub_id,)).fetchone()
logger.info(f"User migrated: {username}")
except sqlite3.IntegrityError:
pass
if not user:
return None, "free"
user = dict(user)
if user['tier'] == 'paid' and user['tariff_days_remaining'] <= 0:
conn.execute("UPDATE users SET tier = 'free' WHERE id = ?", (user['id'],))
conn.commit()
user['tier'] = 'free'
return user, user['tier']
finally:
conn.close()
def get_servers_for_tier(tier: str) -> List[dict]:
result = []
for srv in servers:
if not srv.get("is_active"):
continue
result.append(srv)
return result
def deduplicate_inbounds(servers_list: List[dict], tier: str) -> List[Tuple[dict, dict]]:
seen = set()
result = []
for srv in servers_list:
for inbound in srv.get("inbounds", []):
if tier == "free" and not inbound.get("is_free", True):
continue
key = (srv["name"], inbound.get("name", ""))
if key not in seen:
seen.add(key)
result.append((srv, inbound))
return result
app = FastAPI(title="ZernProxy Manager", docs_url=None, redoc_url=None)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
ADMIN_USER = settings.get("admin", {}).get("username", "admin")
ADMIN_PASS = settings.get("admin", {}).get("password", "")
def check_admin(request: Request) -> bool:
if not ADMIN_PASS:
return True
token = request.cookies.get("admin_token", "")
if token == ADMIN_PASS:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Basic "):
try:
decoded = base64.b64decode(auth[6:]).decode()
u, p = decoded.split(":", 1)
if u == ADMIN_USER and p == ADMIN_PASS:
return True
except:
pass
return False
ADMIN_LOGIN_PAGE = '''
<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Вход</title><style>
body{font-family:Roboto,sans-serif;background:#121212;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1e1e1e;padding:32px;border-radius:16px;max-width:360px;width:90%;text-align:center}
h2{margin:0 0 20px;font-weight:500}.form-group{margin-bottom:16px;text-align:left}
label{display:block;margin-bottom:8px;color:#aaa;font-size:14px}
input{width:100%;padding:12px;border:1px solid #333;border-radius:8px;background:#2a2a2a;color:#fff;font-size:14px;box-sizing:border-box}
.btn{width:100%;padding:12px;background:#4CAF50;color:#fff;border:none;border-radius:8px;font-size:14px;cursor:pointer}
.btn:hover{background:#45a049}</style></head><body>
<div class="card"><h2>🔐 Вход</h2>
<form method="post" action="/admin/login">
<div class="form-group"><label>Пользователь</label><input type="text" name="username" required></div>
<div class="form-group"><label>Пароль</label><input type="password" name="password" required></div>
<button class="btn" type="submit">Войти</button>
</form></div></body></html>'''
@app.post("/admin/login")
async def admin_login(request: Request):
data = await request.form()
if data.get("username") == ADMIN_USER and data.get("password") == ADMIN_PASS:
resp = RedirectResponse(url="/admin/users")
resp.set_cookie(key="admin_token", value=ADMIN_PASS, max_age=86400 * 7, httponly=True)
return resp
return HTMLResponse(content=ADMIN_LOGIN_PAGE.replace("Вход", "Вход · неверный пароль"), status_code=401)
@app.get("/sub/{subscription_id}")
async def get_subscription(request: Request, subscription_id: str, format: str = Query("base64", pattern="^(json|base64|raw)$")):
accept = request.headers.get("accept", "")
if "text/html" in accept and format == "base64":
return await get_web_page(subscription_id)
user, tier = get_user_tier(subscription_id)
if not user:
raise HTTPException(404, "User not found")
if not user.get("is_active"):
raise HTTPException(403, "User is disabled")
tier_config = settings.get("tiers", {}).get(tier, {})
servers_for_tier = get_servers_for_tier(tier)
inbounds = deduplicate_inbounds(servers_for_tier, tier)
if not inbounds:
raise HTTPException(404, "No servers available")
all_links = get_cached_links(subscription_id)
if all_links is None:
all_links = []
seen_links = set()
servers_processed = set()
server_links = {}
for srv, inbound in inbounds:
srv_name = srv["name"]
if srv_name not in servers_processed:
servers_processed.add(srv_name)
sub_path = srv["sub_path"].format(sub_id=subscription_id)
url = f"{srv['subscription_url'].rstrip('/')}{sub_path}"
server_links[srv_name] = await fetch_vless_links(url)
links = server_links.get(srv_name, [])
srv_inbounds = [ib for s, ib in inbounds if s["name"] == srv_name]
try:
link_idx = srv_inbounds.index(inbound)
except ValueError:
continue
if link_idx >= len(links):
continue
link = links[link_idx]
clean_link = link.split('#')[0]
if clean_link in seen_links:
continue
seen_links.add(clean_link)
remark = f"{get_flag_emoji(srv.get('country', ''))} {srv_name.upper()} ({inbound['name']})"
all_links.append(f"{clean_link}#{remark}")
set_cached_links(subscription_id, all_links)
if not all_links:
raise HTTPException(404, "No links found")
if format == "json":
return {"links": all_links, "count": len(all_links), "tier": tier}
lines = []
motd_text = get_motd()
announce_header = ""
if motd_text:
announce_header = f"base64:{base64.b64encode(motd_text.encode()).decode()}"
elif settings.get("announcement"):
announce_header = f"base64:{base64.b64encode(settings['announcement'].encode()).decode()}"
host = settings.get("general", {}).get("host", "conn.zernmc.ru")
title = settings.get("general", {}).get("title", "ZernProxy")
support_url = settings.get("general", {}).get("support_url", "")
update_interval = 12
web_url = f"https://{host}/sub/{subscription_id}"
lines.append(f"#profile-web-page-url: {web_url}")
lines.append(f"#profile-title: {title}")
lines.append(f"#profile-update-interval: {update_interval}")
lines.append("#hide-settings: 1")
if support_url:
lines.append(f"#support-url: {support_url}")
expire_ts = 0
if user.get("tariff_days_remaining", 0) > 0:
expire_ts = int((datetime.now() + timedelta(days=user["tariff_days_remaining"])).timestamp())
lines.append("#sub-expire: 1")
if support_url:
lines.append(f"#sub-expire-button-link: {support_url}")
traffic_limit = user.get("traffic_limit_gb") or tier_config.get("traffic_limit_gb", 0)
traffic_limit_bytes = traffic_limit * 1073741824 if traffic_limit > 0 else 0
traffic = get_cached_traffic(subscription_id)
if not traffic:
traffic = get_traffic_stats(subscription_id)
set_cached_traffic(subscription_id, traffic)
upload = traffic.get("total_up", 0)
download = traffic.get("total_down", 0)
lines.append(f"#subscription-userinfo: upload={upload}; download={download}; total={traffic_limit_bytes}; expire={expire_ts}")
if announce_header:
lines.append(f"#announce: {announce_header}")
lines.extend(all_links)
content = "\n".join(lines)
headers = {
"Profile-Title": title,
"Profile-Update-Interval": str(update_interval),
"Profile-Web-Page-Url": web_url,
"Subscription-Userinfo": f"upload={upload}; download={download}; total={traffic_limit_bytes}; expire={expire_ts}",
"Content-Disposition": f"attachment; filename={title}_{user['username']}",
}
if announce_header:
headers["Announce"] = announce_header
if support_url:
headers["Support-Url"] = support_url
headers["Hide-Settings"] = "1"
if expire_ts:
headers["Sub-Expire"] = "1"
if support_url:
headers["Sub-Expire-Button-Link"] = support_url
if format == "base64":
return Response(content=base64.b64encode(content.encode()).decode(), media_type="text/plain; charset=utf-8", headers=headers)
return Response(content=content, media_type="text/plain; charset=utf-8", headers=headers)
async def get_web_page(subscription_id: str):
user, tier = get_user_tier(subscription_id)
if not user:
raise HTTPException(404, "User not found")
tier_config = settings.get("tiers", {}).get(tier, {})
servers_for_tier = get_servers_for_tier(tier)
inbounds = deduplicate_inbounds(servers_for_tier, tier)
host = settings.get("general", {}).get("host", "conn.zernmc.ru")
title = settings.get("general", {}).get("title", "ZernProxy")
announcement = get_motd() or settings.get("announcement", "")
da_config = settings.get("payments", {}).get("donationalerts", {})
sub_url = f"https://{host}/sub/{subscription_id}"
qr_base64 = generate_qr_base64(sub_url, size=300)
logo_base64 = get_logo_base64()
logo_html = f'<img src="data:image/png;base64,{logo_base64}" alt="Logo" class="logo-img">' if logo_base64 else '<div class="logo-emoji">⚡</div>'
announcement_html = f'<div class="announcement">{announcement}</div>' if announcement else ''
tier_color = "#4CAF50" if tier == "paid" else "#757575"
tier_name = tier_config.get("name", "Free")
tier_badge = f'<span class="tier-badge" style="background: {tier_color}">{tier_name}</span>'
days_remaining = user.get("tariff_days_remaining", 0)
days_info = f"<p>⏳ Осталось дней: {days_remaining}</p>" if tier == "paid" and days_remaining > 0 else ""
traffic = get_cached_traffic(subscription_id)
if not traffic:
traffic = get_traffic_stats(subscription_id)
set_cached_traffic(subscription_id, traffic)
traffic_limit_val = user.get('traffic_limit_gb') or tier_config.get('traffic_limit_gb', 0)
traffic_limit_str = "" if traffic_limit_val == 0 else f"{traffic_limit_val} GB"
traffic_info = f"<p>📊 Лимит: {traffic_limit_str}</p><p>⬆️ {format_bytes(traffic['total_up'])} | ⬇️ {format_bytes(traffic['total_down'])}</p>"
traffic_details = ""
if traffic.get("by_server"):
for srv_name, data in traffic["by_server"].items():
if data["up"] or data["down"]:
traffic_details += f'<div class="traffic-server"><span class="server-name">{srv_name}</span>: <span class="traffic-values">⬆ {format_bytes(data["up"])}{format_bytes(data["down"])}</span></div>'
info_html = f'<div class="info-block">{days_info}{traffic_info}{traffic_details}</div>' if days_info or traffic_info or traffic_details else ''
servers_html = "".join(f'<span class="server-tag">{get_flag_emoji(srv.get("country", ""))} {srv["name"].upper()}</span>' for srv in servers_for_tier)
support_btn = ""
if da_config.get("enabled"):
da_url = da_config.get("url", "#")
support_btn = f'''
<a href="{da_url}" class="btn btn-support" target="_blank">
<span class="material-icons">favorite</span> Поддержать проект
</a>
'''
html = f'''<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Roboto', sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; color: #fff; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.card {{ background: rgba(255,255,255,0.05); backdrop-filter: blur(10px); border-radius: 16px; padding: 24px; margin-bottom: 20px; border: 1px solid rgba(255,255,255,0.1); }}
.header {{ text-align: center; padding: 20px 0; }}
.logo-img {{ width: 80px; height: 80px; border-radius: 12px; }}
.logo-emoji {{ font-size: 60px; }}
h1 {{ font-size: 24px; font-weight: 500; margin: 16px 0; }}
.tier-badge {{ display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; text-transform: uppercase; }}
.announcement {{ background: rgba(76,175,80,0.2); border-left: 4px solid #4CAF50; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; }}
.info-block {{ background: rgba(255,255,255,0.05); border-radius: 12px; padding: 16px; margin-bottom: 20px; }}
.info-block p {{ display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 14px; }}
.traffic-server {{ font-size: 12px; color: #aaa; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); }}
.traffic-server .server-name {{ color: #888; }}
.traffic-server .traffic-values {{ color: #4CAF50; }}
.qr-container {{ text-align: center; padding: 20px; }}
.qr-container img {{ border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
.sub-url {{ background: rgba(255,255,255,0.1); padding: 12px; border-radius: 8px; word-break: break-all; font-size: 12px; color: #aaa; margin-top: 12px; }}
.btn {{ display: inline-flex; align-items: center; gap: 8px; padding: 12px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.3s; }}
.btn-primary {{ background: #4CAF50; color: #fff; }}
.btn-primary:hover {{ background: #45a049; transform: translateY(-2px); }}
.btn-support {{ background: #E91E63; color: #fff; margin-top: 16px; }}
.btn-support:hover {{ background: #C2185B; }}
.servers {{ display: flex; flex-wrap: wrap; gap: 8px; }}
.server-tag {{ background: rgba(255,255,255,0.1); padding: 6px 12px; border-radius: 20px; font-size: 12px; }}
.footer {{ text-align: center; padding: 20px; color: #666; font-size: 12px; }}
@media (max-width: 480px) {{ .container {{ padding: 12px; }} .card {{ padding: 16px; }} }}
</style>
</head>
<body>
<div class="container">
<div class="card header">
{logo_html}
<h1>{title}</h1>
{tier_badge}
</div>
{announcement_html}
<div class="card">
{info_html}
<div class="servers">{servers_html}</div>
</div>
<div class="card">
<div class="qr-container">
<img src="data:image/png;base64,{qr_base64}" alt="QR Code">
<div class="sub-url">{sub_url}</div>
<a href="{sub_url}" class="btn btn-primary" style="margin-top: 16px;">
<span class="material-icons">download</span> Скачать подписку
</a>
{support_btn}
</div>
</div>
<div class="footer">
<p>© 2026 {title}</p>
</div>
</div>
</body>
</html>'''
return HTMLResponse(content=html.replace("{sub_url}", sub_url).replace("{host}", host))
@app.post("/payment/webhook/donationalerts")
async def webhook_donationalerts(request: Request):
da_config = settings.get("payments", {}).get("donationalerts", {})
webhook_secret = da_config.get("webhook_secret", "")
if webhook_secret:
provided = request.headers.get("X-Webhook-Secret", "")
if provided != webhook_secret:
return JSONResponse({"error": "Invalid secret"}, status_code=403)
try:
data = await request.json()
except:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
amount = data.get("amount", 0)
username = data.get("username", "")
message = data.get("message", "")
donation_id = data.get("id", 0)
logger.info(f"DA webhook: id={donation_id} amount={amount} username={username}")
user = None
message_parts = message.split() if message else []
for part in message_parts:
conn = get_db()
try:
if part.isdigit():
user = conn.execute("SELECT * FROM users WHERE id = ?", (int(part),)).fetchone()
if not user:
user = conn.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (part,)).fetchone()
if user:
break
finally:
conn.close()
if not user and username:
conn = get_db()
try:
user = conn.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,)).fetchone()
finally:
conn.close()
if not user:
return JSONResponse({"status": "ignored", "reason": "user_not_found"})
tiers_config = settings.get("tiers", {})
tier = None
days = 0
for tier_name, tier_data in tiers_config.items():
prices = tier_data.get("prices", {})
tier_days = tier_data.get("days", {})
for price_key, price_val in prices.items():
if amount == price_val:
tier = tier_name
days = tier_days.get(price_key, 30)
break
if tier:
break
if not tier:
return JSONResponse({"status": "ignored", "reason": "amount_not_recognized"})
conn = get_db()
try:
conn.execute("""
UPDATE users SET
tier = ?,
tariff_days_bought = tariff_days_bought + ?,
tariff_days_remaining = tariff_days_remaining + ?,
total_paid_rubles = total_paid_rubles + ?
WHERE id = ?
""", (tier, days, days, amount, user["id"]))
conn.commit()
finally:
conn.close()
logger.info(f"VPN payment: {username} paid {amount} RUB, +{days} days")
return JSONResponse({"status": "ok", "user": user["username"], "days": days})
@app.get("/admin/users")
async def admin_users(request: Request):
if not check_admin(request):
return HTMLResponse(content=ADMIN_LOGIN_PAGE)
conn = get_db()
try:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
finally:
conn.close()
da_config = settings.get("payments", {}).get("donationalerts", {})
html = f'''<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Управление - {settings.get("general", {}).get("title", "ZernProxy")}</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Roboto', sans-serif; background: #121212; min-height: 100vh; color: #fff; }}
.container {{ max-width: 1200px; margin: 0 auto; padding: 20px; }}
.header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }}
h1 {{ font-size: 24px; font-weight: 500; }}
.btn {{ display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }}
.btn-small {{ padding: 6px 10px; font-size: 12px; background: #333; color: #fff; border: none; border-radius: 4px; cursor: pointer; }}
.btn-small:hover {{ background: #444; }}
.btn-danger {{ background: #f44336; }}
.btn-danger:hover {{ background: #d32f2f; }}
.btn-primary {{ background: #4CAF50; color: #fff; }}
.card {{ background: #1e1e1e; border-radius: 12px; padding: 20px; margin-bottom: 16px; }}
.table {{ width: 100%; border-collapse: collapse; }}
.table th, .table td {{ padding: 12px; text-align: left; border-bottom: 1px solid #333; }}
.table th {{ color: #aaa; font-weight: 500; font-size: 12px; text-transform: uppercase; }}
.badge {{ display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }}
.badge-free {{ background: #757575; }}
.badge-paid {{ background: #4CAF50; }}
.modal {{ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; }}
.modal.active {{ display: flex; align-items: center; justify-content: center; }}
.modal-content {{ background: #1e1e1e; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; }}
.form-group {{ margin-bottom: 16px; }}
.form-group label {{ display: block; margin-bottom: 8px; color: #aaa; font-size: 14px; }}
.form-group input {{ width: 100%; padding: 12px; border: 1px solid #333; border-radius: 8px; background: #2a2a2a; color: #fff; font-size: 14px; }}
@media (max-width: 768px) {{ .table {{ display: block; overflow-x: auto; }} }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>👥 Пользователи ({len(users)})</h1>
<button class="btn btn-primary" onclick="openModal()">
<span class="material-icons">add</span> Добавить
</button>
</div>
<div class="card">
<table class="table">
<thead>
<tr><th>ID</th><th>Username</th><th>Sub ID</th><th>Тариф</th><th>Дней</th><th>Оплачено</th><th>Трафик</th><th>Статус</th><th>Создан</th></tr>
</thead>
<tbody>
{"".join(f'''<tr>
<td>{u['id']}</td>
<td>{u['username']}</td>
<td><code style="font-size:10px">{u['subscription_id']}</code></td>
<td><span class="badge badge-{u['tier']}">{u['tier'].upper()}</span></td>
<td>{u['tariff_days_remaining']}</td>
<td>{u['total_paid_rubles']}₽</td>
<td>{u['traffic_limit_gb'] if u['traffic_limit_gb'] > 0 else ''} GB</td>
<td>{"" if u['is_active'] else ""}</td>
<td>{u['created_at'][:10]}</td>
<td>
<button class="btn-small" onclick="editUser({u['id']}, '{u['tier']}', {u['tariff_days_remaining']}, {u['traffic_limit_gb']}, {u['is_active']})">✏️</button>
<button class="btn-small btn-danger" onclick="deleteUser({u['id']}, '{u['username']}')">🗑️</button>
</td>
</tr>''' for u in users)}
</tbody>
</table>
</div>
</div>
<div class="modal" id="userModal">
<div class="modal-content">
<div class="modal-header">
<h2>Добавить пользователя</h2>
<button class="close-btn" onclick="closeModal()">×</button>
</div>
<form id="userForm">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Лимит трафика (GB, 0 = ∞)</label>
<input type="number" name="traffic_limit_gb" value="0" min="0">
</div>
<button type="submit" class="btn btn-primary">Создать</button>
</form>
</div>
</div>
<script>
function openModal() {{ document.getElementById('userModal').classList.add('active'); }}
function closeModal() {{ document.getElementById('userModal').classList.remove('active'); }}
document.getElementById('userForm').addEventListener('submit', async (e) => {{
e.preventDefault();
const formData = new FormData(e.target);
const username = formData.get('username');
const btn = e.target.querySelector('button');
btn.disabled = true;
btn.textContent = 'Создание...';
const response = await fetch('/admin/api/users', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
username,
traffic_limit_gb: parseInt(formData.get('traffic_limit_gb')) || 0
}})
}});
const result = await response.json();
if (response.ok) {{
let msg = `Пользователь \\`${{username}}\\` создан!\\n`;
msg += `Sub ID: \\`${{result.subscription_id}}\\`\\n`;
msg += `Подписка: ${{result.subscription_url}}\\n\\n`;
msg += result.results.map(r => {{
const ibs = r.inbounds.map(ib => `${{ib.name}}: ${{ib.success ? '' : '' + (ib.error || '')}}`).join('\\n');
return `${{r.server}}:\\n${{ibs}}`;
}}).join('\\n\\n');
msg += `\\n\\n${{result.summary}}`;
alert(msg);
location.reload();
}} else {{
alert('Ошибка: ' + (result.error || 'unknown'));
btn.disabled = false;
btn.textContent = 'Создать';
}}
}});
function editUser(id, tier, days, traffic, active) {{
document.getElementById('editId').value = id;
document.getElementById('editTier').value = tier;
document.getElementById('editDays').value = days;
document.getElementById('editTraffic').value = traffic;
document.getElementById('editActive').checked = active == 1;
document.getElementById('editModal').classList.add('active');
}}
function closeEditModal() {{
document.getElementById('editModal').classList.remove('active');
}}
async function submitEditForm() {{
const form = document.getElementById('editForm');
const formData = new FormData(form);
const data = {{
id: formData.get('id'),
tier: formData.get('tier'),
tariff_days_remaining: parseInt(formData.get('tariff_days_remaining')) || 0,
traffic_limit_gb: formData.get('traffic_limit_gb') === '' ? 0 : parseInt(formData.get('traffic_limit_gb')),
is_active: formData.get('is_active') === 'on'
}};
const response = await fetch('/admin/api/users/update', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(data)
}});
if (response.ok) {{
alert('Сохранено!');
location.reload();
}} else {{
const result = await response.json();
alert('Ошибка: ' + (result.error || 'unknown'));
}}
}}
function deleteUser(id, username) {{
if (confirm('Удалить пользователя ' + username + '? Это удалит его со всех серверов!')) {{
fetch('/admin/api/users/delete', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{id, username}})
}}).then(r => {{
if (r.ok) {{
alert('Удалён!');
location.reload();
}} else {{
alert('Ошибка удаления');
}}
}});
}}
}}
</script>
<div class="modal" id="editModal">
<div class="modal-content">
<div class="modal-header">
<h2>Редактировать пользователя</h2>
<button class="close-btn" onclick="closeEditModal()">×</button>
</div>
<form id="editForm">
<input type="hidden" name="id" id="editId">
<div class="form-group">
<label>Тариф</label>
<select name="tier" id="editTier">
<option value="free">Free</option>
<option value="paid">Paid</option>
</select>
</div>
<div class="form-group">
<label>Дней осталось</label>
<input type="number" name="tariff_days_remaining" id="editDays">
</div>
<div class="form-group">
<label>Лимит трафика (GB, 0 = ∞)</label>
<input type="number" name="traffic_limit_gb" id="editTraffic">
</div>
<div class="form-group">
<label><input type="checkbox" name="is_active" id="editActive"> Активен</label>
</div>
<button type="button" class="btn btn-primary" onclick="submitEditForm()">Сохранить</button>
</form>
</div>
</div>
</body>
</html>'''
return HTMLResponse(content=html)
@app.get("/admin/dashboard")
async def admin_dashboard(request: Request):
if not check_admin(request):
return HTMLResponse(content=ADMIN_LOGIN_PAGE)
conn = get_db()
try:
total_users = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
free_users = conn.execute("SELECT COUNT(*) as c FROM users WHERE tier='free'").fetchone()["c"]
paid_users = conn.execute("SELECT COUNT(*) as c FROM users WHERE tier='paid'").fetchone()["c"]
test_users = conn.execute("SELECT COUNT(*) as c FROM users WHERE tier='test'").fetchone()["c"]
total_revenue = conn.execute("SELECT SUM(total_paid_rubles) as s FROM users").fetchone()["s"] or 0
total_donations = conn.execute("SELECT SUM(total_paid_rubles) as s FROM users WHERE total_paid_rubles > 0").fetchone()["s"] or 0
finally:
conn.close()
online_count = 0
for srv in servers:
for inbound in srv.get("inbounds", []):
try:
api = Api(host=inbound["api_host"], username=inbound["api_user"], password=inbound["api_pass"], use_tls_verify=False)
api.login()
online = api.client.online()
online_count += len(online)
except:
pass
html = f'''<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Дашборд - ZernProxy</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:Roboto,sans-serif;background:#121212;color:#fff;min-height:100vh}}
.container{{max-width:1200px;margin:0 auto;padding:20px}}
.header{{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:12px}}
h1{{font-size:24px}}
.nav{{display:flex;gap:12px}}
.nav a{{color:#4CAF50;text-decoration:none;padding:8px 16px;border-radius:8px}}
.nav a:hover{{background:#2a2a2a}}
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:24px}}
.card{{background:#1e1e1e;border-radius:12px;padding:20px}}
.card h3{{color:#aaa;font-size:14px;font-weight:400;margin-bottom:8px}}
.card .val{{font-size:28px;font-weight:500}}
.card .sub{{font-size:12px;color:#666;margin-top:4px}}
.green{{color:#4CAF50}}.blue{{color:#2196F3}}.orange{{color:#FF9800}}.red{{color:#f44336}}
@media(max-width:600px){{.header{{flex-direction:column;align-items:start}}}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Дашборд</h1>
<div class="nav">
<a href="/admin/users">👥 Пользователи</a>
<a href="/admin/dashboard">📊 Дашборд</a>
</div>
</div>
<div class="grid">
<div class="card"><h3>Всего пользователей</h3><div class="val green">{total_users}</div></div>
<div class="card"><h3>Free</h3><div class="val blue">{free_users}</div></div>
<div class="card"><h3>Test</h3><div class="val orange">{test_users}</div></div>
<div class="card"><h3>Paid</h3><div class="val green">{paid_users}</div></div>
<div class="card"><h3>Онлайн</h3><div class="val blue">{online_count}</div><div class="sub">на всех серверах</div></div>
<div class="card"><h3>Выручка</h3><div class="val green">{total_revenue}₽</div><div class="sub">всего оплат</div></div>
<div class="card"><h3>Серверов</h3><div class="val blue">{len(servers)}</div></div>
<div class="card"><h3>Инбаундов</h3><div class="val orange">{sum(len(s.get("inbounds",[])) for s in servers)}</div></div>
</div>
<div class="card">
<h3>Серверы</h3>
{"".join(f'<div style="margin-top:12px;padding:12px;background:#2a2a2a;border-radius:8px;display:flex;justify-content:space-between"><span>{get_flag_emoji(s.get("country",""))} {s["name"].upper()}</span><span style="color:#aaa">{len(s.get("inbounds",[]))} inbound</span></div>' for s in servers)}
</div>
</div>
</body>
</html>'''
return HTMLResponse(content=html)
@app.get("/")
async def home_page():
title = settings.get("general", {}).get("title", "ZernProxy")
da_url = settings.get("payments", {}).get("donationalerts", {}).get("url", "")
conn = get_db()
try:
total = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
finally:
conn.close()
statuses = await fetch_servers_status()
servers_html = ""
for i, s in enumerate(statuses):
chk = s.get("checks", {})
cpu = chk.get("CPU", {}).get("value")
ram = chk.get("RAM", {}).get("value")
disk = chk.get("Disk /", {}).get("value")
net_raw = chk.get("Net ↓↑", {}).get("value", "")
server_name = s.get("server_name", s["name"].upper())
delay = 0.3 + i * 0.15
svc_tags = ""
for key in ("Caddy", "Minecraft", "Bio site", "Main site", "3x-UI"):
val = chk.get(key, {}).get("value", "")
if val:
if "🟢" in val or "RUN" in val or "OK" in val or "200" in val:
cl, st = "ok", "🟢"
elif "🔴" in val or "DOWN" in val or "502" in val or "503" in val or "error" in val.lower():
cl, st = "err", "🔴"
else:
cl, st = "warn", "🟡"
svc_tags += f'<span class="svc {cl}" title="{key}: {val}">{st} {key}</span>'
servers_html += f'''
<div class="s-card" style="--d: {delay}s">
<div class="s-head"><span class="flag">{get_flag_emoji(s.get("country",""))}</span> {server_name}</div>
<div class="s-metrics">
<div class="m"><span class="m-l">CPU</span><div class="m-t"><div class="m-f" style="--w: {cpu if cpu is not None else 0}%"></div></div><span class="m-v">{f"{cpu:.1f}%" if cpu is not None else ""}</span></div>
<div class="m"><span class="m-l">RAM</span><div class="m-t"><div class="m-f" style="--w: {ram if ram is not None else 0}%"></div></div><span class="m-v">{f"{ram:.1f}%" if ram is not None else ""}</span></div>
<div class="m"><span class="m-l">DSK</span><div class="m-t"><div class="m-f" style="--w: {disk if disk is not None else 0}%"></div></div><span class="m-v">{f"{disk:.1f}%" if disk is not None else ""}</span></div>
<div class="m"><span class="m-l">NET</span><div class="m-t" style="background:none;align-items:center"><span class="m-net">{net_raw}</span></div></div>
</div>
{f'<div class="s-svc">{svc_tags}</div>' if svc_tags else ''}
</div>'''
return HTMLResponse(content=f'''<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {{
--bg: #080c18;
--surface: rgba(255,255,255,0.03);
--border: rgba(255,255,255,0.06);
--border-hover: rgba(255,255,255,0.12);
--text: #fff;
--text-sec: rgba(255,255,255,0.5);
--text-ter: rgba(255,255,255,0.3);
--accent: #6C63FF;
--green: #10b981;
--amber: #f59e0b;
--rose: #f43f5e;
--card-rad: 16px;
}}
* {{ margin:0; padding:0; box-sizing:border-box }}
html {{ scroll-behavior:smooth }}
body {{
font-family:'Plus Jakarta Sans',sans-serif;
background:var(--bg);
color:var(--text);
min-height:100vh;
overflow-x:hidden;
}}
body::before {{
content:'';
position:fixed;inset:0;
background:
radial-gradient(ellipse 80% 50% at 10% 0%, rgba(108,99,255,0.08) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 90% 100%, rgba(16,185,129,0.06) 0%, transparent 70%);
pointer-events:none;
z-index:0;
}}
.container {{ position:relative; z-index:1; max-width:720px; margin:0 auto; padding:48px 20px 32px }}
/* Header */
.header {{ text-align:center; margin-bottom:36px; animation:fadeUp .7s ease-out forwards }}
.header h1 {{
font-size:32px; font-weight:800; letter-spacing:-.03em;
background:linear-gradient(135deg,#fff 30%,rgba(255,255,255,.6));
-webkit-background-clip:text; -webkit-text-fill-color:transparent;
background-clip:text;
}}
.header p {{ color:var(--text-sec); font-size:15px; margin-top:6px; font-weight:400 }}
/* Stats row */
.stats {{ display:flex; gap:12px; justify-content:center; margin-bottom:36px; animation:fadeUp .7s .1s ease-out both }}
.stat {{
flex:1; max-width:180px;
background:var(--surface); border:1px solid var(--border);
border-radius:var(--card-rad); padding:18px 16px; text-align:center;
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
transition:border-color .3s, transform .3s, box-shadow .3s;
}}
.stat:hover {{ border-color:var(--border-hover); transform:translateY(-2px); box-shadow:0 8px 40px rgba(108,99,255,0.08) }}
.stat .n {{ font-size:28px; font-weight:700; letter-spacing:-.02em }}
.stat .l {{ font-size:12px; color:var(--text-sec); margin-top:4px; font-weight:500 }}
/* Server cards */
.servers {{ display:grid; gap:14px }}
.s-card {{
background:var(--surface); border:1px solid var(--border);
border-radius:var(--card-rad); padding:18px 20px;
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
transition:border-color .4s, transform .4s, box-shadow .4s;
animation:fadeUp .6s ease-out var(--d,0s) both;
}}
.s-card:hover {{ border-color:var(--border-hover); transform:translateY(-3px); box-shadow:0 12px 48px rgba(108,99,255,0.06) }}
.s-head {{ font-size:15px; font-weight:600; margin-bottom:14px; display:flex; align-items:center; gap:8px }}
.s-head .flag {{ font-size:20px; line-height:1 }}
.s-metrics {{ display:grid; gap:10px }}
.m {{ display:grid; grid-template-columns:40px 1fr 50px; align-items:center; gap:10px }}
.m-l {{ font-size:11px; font-weight:600; color:var(--text-sec); letter-spacing:.04em }}
.m-t {{ height:6px; background:rgba(255,255,255,0.06); border-radius:3px; overflow:hidden; display:flex }}
.m-f {{
width:0; height:100%; border-radius:3px;
background:linear-gradient(90deg,var(--accent),var(--green));
animation:fillBar .9s cubic-bezier(.4,0,.2,1) .4s forwards;
}}
.m-v {{ font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--text-sec); text-align:right }}
.m-net {{ font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--text-ter); white-space:nowrap }}
.s-svc {{ margin-top:12px; padding-top:12px; border-top:1px solid var(--border); display:flex; gap:8px; flex-wrap:wrap }}
.svc {{ font-size:11px; padding:3px 8px; border-radius:6px; background:rgba(255,255,255,0.04); font-weight:500 }}
.svc.ok {{ color:var(--green) }}
.svc.err {{ color:var(--rose) }}
.svc.warn {{ color:var(--amber) }}
/* Tech info */
.info {{
margin-top:28px;
background:var(--surface); border:1px solid var(--border);
border-radius:var(--card-rad); padding:20px;
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
animation:fadeUp .6s .5s ease-out both;
transition:border-color .3s;
}}
.info:hover {{ border-color:var(--border-hover) }}
.info h3 {{ font-size:14px; font-weight:600; margin-bottom:12px; color:var(--text) }}
.info-grid {{ display:grid; grid-template-columns:1fr 1fr; gap:8px 16px }}
.info-grid span {{ font-size:12px; color:var(--text-sec); line-height:1.8; display:flex; align-items:center; gap:6px }}
.info-grid span::before {{ content:''; display:inline-block; width:4px; height:4px; border-radius:2px; background:var(--accent); flex-shrink:0 }}
/* Buttons */
.btns {{ display:flex; justify-content:center; gap:10px; margin-top:28px; animation:fadeUp .6s .6s ease-out both }}
.btn {{
display:inline-flex; align-items:center; gap:6px;
padding:12px 28px; border-radius:12px;
font-size:14px; font-weight:600; text-decoration:none;
transition:transform .3s, box-shadow .3s;
cursor:pointer;
}}
.btn:hover {{ transform:translateY(-2px); box-shadow:0 8px 32px rgba(108,99,255,0.2) }}
.btn.da {{ background:linear-gradient(135deg,#f43f5e,#e11d48); color:#fff }}
/* Footer */
.footer {{ text-align:center; margin-top:36px; color:var(--text-ter); font-size:12px; animation:fadeUp .6s .7s ease-out both }}
/* Keyframes */
@keyframes fadeUp {{
from {{ opacity:0; transform:translateY(16px) }}
to {{ opacity:1; transform:translateY(0) }}
}}
@keyframes fillBar {{
from {{ width:0% }}
to {{ width:var(--w) }}
}}
/* Responsive */
@media(max-width:520px) {{
.container {{ padding:32px 14px }}
.header h1 {{ font-size:24px }}
.stat {{ max-width:none; padding:14px 12px }}
.stat .n {{ font-size:22px }}
.s-card {{ padding:14px }}
.m {{ grid-template-columns:34px 1fr 44px; gap:8px }}
.info-grid {{ grid-template-columns:1fr }}
.btns {{ flex-direction:column; align-items:center }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚡ {title}</h1>
<p>Быстрый и надёжный VPN-сервис</p>
</div>
<div class="stats">
<div class="stat"><div class="n">{total}</div><div class="l">Пользователей</div></div>
<div class="stat"><div class="n">{len(servers)}</div><div class="l">Локаций</div></div>
</div>
<div class="servers">{servers_html}</div>
<div class="info">
<h3>🔧 Технические детали</h3>
<div class="info-grid">
<span>VLESS + XTLS Vision / Reality</span>
<span>TCP, WebSocket, gRPC</span>
<span>DDoS-защита на всех серверах</span>
<span>Каналы 110 Gbit/s</span>
<span>Zero-log политика</span>
<span>Поддержка Happ (iOS/Android)</span>
</div>
</div>
<div class="btns">
<a href="{da_url}" class="btn da">❤️ Поддержать проект</a>
</div>
<div class="footer">© 2026 {title}</div>
</div>
</body>
</html>''')
async def fetch_servers_status() -> list:
results = []
async with httpx.AsyncClient(timeout=8.0, verify=False) as client:
for srv in servers:
url = srv.get("status_url", "")
entry = {"name": srv["name"], "country": srv.get("country",""), "checks": {}}
if not url:
results.append(entry)
continue
try:
resp = await client.get(url)
if resp.status_code == 200:
data = resp.json()
entry["server_name"] = data.get("server_name", "")
entry["checks"] = data.get("checks", {})
else:
entry["checks"]["error"] = {"value": f"HTTP {resp.status_code}"}
except Exception as e:
entry["checks"]["error"] = {"value": str(e)[:50]}
results.append(entry)
return results
@app.get("/webhook/tg")
async def tg_webhook():
bot_cfg = settings.get("bot", {})
if bot_cfg.get("enabled"):
return JSONResponse({"status": "ok", "bot": True, "message": "Telegram bot is ready"})
return JSONResponse({"status": "ok", "bot": False, "message": "Telegram bot not configured"})
def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: int = 0) -> dict:
"""Создаёт клиента на 3x-UI сервере"""
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
inbound_id = inbound.get("id")
if not all([api_host, api_user, api_pass, inbound_id]):
return {"success": False, "error": "missing_credentials"}
try:
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
api.login()
inbound_name = inbound.get('name', 'default')
email = f"{username}_{inbound_name}@vless.local"
total_bytes = traffic_gb * 1073741824 if traffic_gb > 0 else 0
logger.info(f"Creating client: email={email}, total_bytes={total_bytes}, inbound={inbound_name}")
from py3xui.client import Client
client = Client(
id=str(uuid.uuid4()),
email=email,
enable=True,
total_gb=total_bytes,
expiry_time=0,
limit_ip=0,
subId=sub_id
)
try:
existing = api.client.get_by_email(email)
if existing:
logger.info(f"Deleting existing client: {existing.id}")
api.client.delete(existing.id, inbound_id)
except:
pass
logger.info(f"About to add client: {client}")
api.client.add(inbound_id=inbound_id, clients=[client])
logger.info(f"Client created successfully on {inbound_name}")
return {"success": True, "email": email}
except Exception as e:
logger.error(f"Error creating client: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "error": str(e)[:150]}
def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict:
"""Удаляет клиента с 3x-UI сервера"""
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
inbound_id = inbound.get("id")
if not all([api_host, api_user, api_pass, inbound_id]):
return {"success": False, "error": "missing_credentials"}
try:
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
api.login()
inbounds = api.inbound.get_list()
for ib in inbounds:
if ib.id == inbound_id and ib.client_stats:
for client in ib.client_stats:
if getattr(client, 'sub_id', '') == sub_id:
api.client.delete(inbound_id, client.uuid)
logger.info(f"Deleted client from {inbound.get('name')}")
return {"success": True}
return {"success": True, "error": "not_found"}
except Exception as e:
return {"success": False, "error": str(e)[:100]}
@app.post("/admin/api/users")
async def create_user(request: Request, data: dict):
if not check_admin(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
username = data.get("username", "").strip()
if not username:
return JSONResponse({"error": "username required"}, status_code=400)
sub_id = generate_sub_id()
conn = get_db()
traffic_gb = int(data.get("traffic_limit_gb", 0) or 0)
logger.info(f"Creating user: username={username}, traffic_gb={traffic_gb}")
results = []
for srv in servers:
srv_result = {"server": srv["name"], "inbounds": []}
for inbound in srv.get("inbounds", []):
result = create_3xui_client(username, sub_id, inbound, traffic_gb)
srv_result["inbounds"].append({
"name": inbound.get("name"),
"success": result["success"],
"error": result.get("error", "")
})
results.append(srv_result)
try:
conn.execute("""
INSERT INTO users (username, subscription_id, tier, tariff_days_bought, tariff_days_remaining, total_paid_rubles, traffic_limit_gb, is_active)
VALUES (?, ?, 'free', 0, 0, 0, 0, 1)
""", (username, sub_id))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
return JSONResponse({"error": "username exists"}, status_code=400)
finally:
conn.close()
success_count = sum(1 for r in results for ib in r["inbounds"] if ib["success"])
total_count = sum(len(r["inbounds"]) for r in results)
return JSONResponse({
"status": "ok",
"username": username,
"subscription_id": sub_id,
"subscription_url": f"https://{settings.get('general', {}).get('host', 'conn.zernmc.ru')}/sub/{sub_id}",
"results": results,
"summary": f"{success_count}/{total_count} инбаундов создано"
})
@app.post("/admin/api/users/update")
async def update_user(request: Request, data: dict):
if not check_admin(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
logger.info(f"RAW data received: {data}")
user_id = int(data.get("id", 0))
tier = str(data.get("tier", "free"))
tariff_days_remaining = int(data.get("tariff_days_remaining", 0) or 0)
traffic_limit_gb = int(data.get("traffic_limit_gb", 0) or 0)
is_active = 1 if data.get("is_active") in [True, "true", "on", "1", 1] else 0
logger.info(f"Update user: id={user_id}, tier='{tier}', days={tariff_days_remaining}, traffic={traffic_limit_gb}, active={is_active}")
conn = get_db()
try:
conn.execute("""
UPDATE users SET tier = ?, tariff_days_remaining = ?, traffic_limit_gb = ?, is_active = ?
WHERE id = ?
""", (tier, tariff_days_remaining, traffic_limit_gb, is_active, user_id))
conn.commit()
updated = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
logger.info(f"After update DB: {dict(updated)}")
finally:
conn.close()
return JSONResponse({"status": "ok"})
@app.post("/admin/api/users/delete")
async def delete_user(request: Request, data: dict):
if not check_admin(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = data.get("id")
username = data.get("username", "")
conn = get_db()
try:
user = conn.execute("SELECT subscription_id FROM users WHERE id = ?", (user_id,)).fetchone()
if not user:
return JSONResponse({"error": "user not found"}, status_code=404)
sub_id = user["subscription_id"]
for srv in servers:
for inbound in srv.get("inbounds", []):
result = delete_3xui_client(username, sub_id, inbound)
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
finally:
conn.close()
return JSONResponse({"status": "ok", "message": "User deleted from all servers"})
async def rotate_shortids():
while True:
try:
rotation_hours = settings.get("shortid_rotation_hours", 11)
await asyncio.sleep(rotation_hours * 3600)
for srv in servers:
for inbound in srv.get("inbounds", []):
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
if not all([api_host, api_user, api_pass]):
continue
try:
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
api.login()
new_shortid = secrets.token_hex(4)
inbounds = api.inbound.get_inbounds()
for ib in inbounds:
if ib.id == inbound.get("id"):
ib.shortid = new_shortid
api.inbound.update_inbound(ib)
logger.info(f"ShortID rotated for {srv['name']}/{inbound['name']}: {new_shortid}")
break
except Exception as e:
logger.error(f"ShortID rotation error {srv['name']}/{inbound['name']}: {e}")
except Exception as e:
logger.error(f"ShortID rotation error: {e}")
await asyncio.sleep(3600)
def update_3xui_expiry(sub_id: str, days: int):
"""Обновляет expiry_time на всех 3x-UI серверах"""
import time
expiry_timestamp = int(time.time() + (days * 86400))
for srv in servers:
for inbound in srv.get("inbounds", []):
api_host = inbound.get("api_host")
api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass")
inbound_id = inbound.get("id")
if not all([api_host, api_user, api_pass, inbound_id]):
continue
try:
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
api.login()
inbounds = api.inbound.get_list()
for ib in inbounds:
if ib.id == inbound_id and ib.client_stats:
for client in ib.client_stats:
if getattr(client, 'sub_id', '') == sub_id:
client.expiry_time = expiry_timestamp
api.client.update(client.uuid, client)
logger.info(f"Updated expiry for {srv['name']}/{inbound['name']}: {days} days")
break
except Exception as e:
logger.error(f"Error updating expiry {srv['name']}/{inbound['name']}: {e}")
async def poll_donationalerts():
global last_donation_id
while True:
try:
da_config = settings.get("payments", {}).get("donationalerts", {})
if not da_config.get("enabled"):
await asyncio.sleep(300)
continue
api_token = da_config.get("api_token", "")
if not api_token:
logger.warning("DonationAlerts API token not configured")
await asyncio.sleep(300)
continue
interval = da_config.get("check_interval_minutes", 5)
await asyncio.sleep(interval * 60)
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(
"https://www.donationalerts.com/api/v1/alerts/donations",
headers={"Authorization": f"Bearer {api_token}"}
)
if resp.status_code != 200:
logger.error(f"DA API error: {resp.status_code}")
continue
data = resp.json()
donations = data.get("data", [])
for donation in reversed(donations):
donation_id = donation.get("id")
if donation_id <= last_donation_id:
continue
amount = donation.get("amount", 0)
username = donation.get("username", "")
message = donation.get("message", "")
tiers_config = settings.get("tiers", {})
tier = None
days = 0
for tier_name, tier_data in tiers_config.items():
prices = tier_data.get("prices", {})
tier_days = tier_data.get("days", {})
for price_key, price_val in prices.items():
if amount == price_val:
tier = tier_name
days = tier_days.get(price_key, 30)
break
if tier:
break
if not tier:
logger.info(f"DA: ignoring amount {amount} RUB (not in config)")
last_donation_id = donation_id
continue
user = None
message_parts = message.split() if message else []
conn = get_db()
try:
for part in message_parts:
if part.isdigit():
user = conn.execute("SELECT * FROM users WHERE id = ?", (int(part),)).fetchone()
if not user:
user = conn.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (part,)).fetchone()
if user:
break
if not user and username:
user = conn.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,)).fetchone()
if user:
current_expiry = user.get("tariff_days_remaining", 0)
if current_expiry > 0:
new_expiry = current_expiry + days
else:
new_expiry = days
conn.execute("""
UPDATE users SET
tier = ?,
tariff_days_bought = tariff_days_bought + ?,
tariff_days_remaining = ?,
total_paid_rubles = total_paid_rubles + ?
WHERE id = ?
""", (tier, days, new_expiry, amount, user["id"]))
conn.commit()
sub_id = user["subscription_id"]
update_3xui_expiry(sub_id, days)
logger.info(f"VPN payment: {username} paid {amount} RUB, tier={tier}, +{days} days")
finally:
conn.close()
last_donation_id = donation_id
except Exception as e:
logger.error(f"DonationAlerts polling error: {e}")
await asyncio.sleep(300)
@app.get("/health")
async def health():
return {"status": "ok", "servers": len(servers), "settings_loaded": bool(settings)}
@app.post("/admin/api/reload")
async def reload_configs(request: Request):
if not check_admin(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
load_configs()
clear_cache()
return {"status": "ok"}
@app.get("/admin/api/rotate-shortids")
async def manual_rotate(request: Request):
if not check_admin(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
await rotate_shortids()
return {"status": "ok"}
if __name__ == "__main__":
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.create_task(rotate_shortids())
loop.create_task(poll_donationalerts())
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")