1718 lines
73 KiB
Python
1718 lines
73 KiB
Python
#!/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()
|
||
|
||
# Uptime tracker: first_seen per server
|
||
_first_seen: Dict[str, float] = {}
|
||
|
||
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", "")
|
||
host = settings.get("general", {}).get("host", "zernmc.ru")
|
||
|
||
conn = get_db()
|
||
try:
|
||
total = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
|
||
finally:
|
||
conn.close()
|
||
|
||
statuses = await fetch_servers_status()
|
||
|
||
srv_cards = ""
|
||
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", "")
|
||
srv_name = s.get("server_name", s["name"].upper())
|
||
delay = 0.45 + i * 0.15
|
||
online = s.get("online", False)
|
||
|
||
inbounds = [ib for ib in servers if ib["name"] == s["name"]]
|
||
for srv_cfg in servers:
|
||
if srv_cfg["name"] == s["name"]:
|
||
all_ib = srv_cfg.get("inbounds", [])
|
||
has_free = any(ib.get("is_free", True) for ib in all_ib)
|
||
has_paid = any(not ib.get("is_free", True) for ib in all_ib)
|
||
break
|
||
else:
|
||
has_free = True
|
||
has_paid = True
|
||
|
||
if has_free and has_paid:
|
||
badge = '<span class="tier both">Free + Premium</span>'
|
||
elif has_free:
|
||
badge = '<span class="tier free">Free</span>'
|
||
else:
|
||
badge = '<span class="tier prem">Premium</span>'
|
||
|
||
uptime_str = s.get("uptime", "")
|
||
uptime_html = f'<span class="upt on">⏱ {uptime_str}</span>' if online else '<span class="upt off">⏱ Offline</span>'
|
||
|
||
srv_cards += f'''
|
||
<div class="s-card" style="--d: {delay}s">
|
||
<div class="s-head"><span class="flag">{get_flag_emoji(s.get("country",""))}</span> {srv_name} {badge} {uptime_html}</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>
|
||
</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 }}
|
||
|
||
.section {{ animation:fadeUp .6s ease-out var(--d,0s) both; margin-bottom:40px }}
|
||
|
||
/* Header */
|
||
.header {{ text-align:center; --d:0.1s }}
|
||
.header h1 {{
|
||
font-size:34px; 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:8px; line-height:1.6 }}
|
||
.header .sub {{ color:var(--text-ter); font-size:13px; margin-top:4px }}
|
||
|
||
/* Why us */
|
||
.why {{ --d:0.2s }}
|
||
.why h2 {{ font-size:18px; font-weight:600; margin-bottom:14px }}
|
||
.why-grid {{ display:grid; grid-template-columns:1fr 1fr; gap:10px }}
|
||
.w-card {{
|
||
background:var(--surface); border:1px solid var(--border);
|
||
border-radius:12px; padding:14px;
|
||
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
|
||
transition:border-color .3s, transform .3s;
|
||
animation:fadeUp .5s ease-out calc(var(--d,0s) + var(--i,0)*0.06s) both;
|
||
}}
|
||
.w-card:hover {{ border-color:var(--border-hover); transform:translateY(-2px) }}
|
||
.w-card .e {{ font-size:20px; margin-bottom:6px }}
|
||
.w-card h4 {{ font-size:13px; font-weight:600; margin-bottom:3px }}
|
||
.w-card p {{ font-size:11px; color:var(--text-sec); line-height:1.5 }}
|
||
|
||
/* Servers */
|
||
.srv-section {{ --d:0.35s }}
|
||
.srv-section h2 {{ font-size:18px; font-weight:600; margin-bottom:14px }}
|
||
.servers {{ display:grid; gap:14px }}
|
||
|
||
/* Server card */
|
||
.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:14px; font-weight:600; margin-bottom:14px; display:flex; align-items:center; gap:6px; flex-wrap:wrap }}
|
||
.s-head .flag {{ font-size:20px; line-height:1 }}
|
||
.tier {{ font-size:10px; font-weight:600; padding:2px 8px; border-radius:20px; letter-spacing:.02em }}
|
||
.tier.both {{ background:rgba(108,99,255,0.15); color:var(--accent) }}
|
||
.tier.free {{ background:rgba(16,185,129,0.12); color:var(--green) }}
|
||
.tier.prem {{ background:rgba(245,158,11,0.12); color:var(--amber) }}
|
||
.upt {{ font-size:11px; font-weight:500; font-family:'JetBrains Mono',monospace }}
|
||
.upt.on {{ color:var(--green) }}
|
||
.upt.off {{ color:var(--rose) }}
|
||
.s-metrics {{ display:grid; gap:10px }}
|
||
.m {{ display:grid; grid-template-columns:38px 1fr 48px; 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) .5s 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 }}
|
||
|
||
/* Tech info */
|
||
.tech {{ --d:0.5s }}
|
||
.tech h2 {{ font-size:18px; font-weight:600; margin-bottom:14px }}
|
||
.tech-grid {{
|
||
display:grid; grid-template-columns:1fr 1fr;
|
||
background:var(--surface); border:1px solid var(--border);
|
||
border-radius:var(--card-rad); padding:20px;
|
||
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
|
||
transition:border-color .3s;
|
||
}}
|
||
.tech-grid:hover {{ border-color:var(--border-hover) }}
|
||
.t-item {{ font-size:12px; color:var(--text-sec); padding:8px 0; display:flex; align-items:center; gap:8px; border-bottom:1px solid var(--border) }}
|
||
.t-item:last-child {{ border-bottom:none }}
|
||
.t-item .t-dot {{ width:4px; height:4px; border-radius:2px; background:var(--accent); flex-shrink:0 }}
|
||
|
||
/* Buttons */
|
||
.btns {{ display:flex; justify-content:center; gap:10px; margin-top:12px; animation:fadeUp .6s .55s 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 {{ text-align:center; margin-top:40px; color:var(--text-ter); font-size:12px; animation:fadeUp .6s .6s ease-out both }}
|
||
|
||
@keyframes fadeUp {{
|
||
from {{ opacity:0; transform:translateY(16px) }}
|
||
to {{ opacity:1; transform:translateY(0) }}
|
||
}}
|
||
@keyframes fillBar {{
|
||
from {{ width:0% }}
|
||
to {{ width:var(--w) }}
|
||
}}
|
||
|
||
@media(max-width:520px) {{
|
||
.container {{ padding:32px 14px }}
|
||
.header h1 {{ font-size:24px }}
|
||
.why-grid {{ grid-template-columns:1fr }}
|
||
.s-card {{ padding:14px }}
|
||
.m {{ grid-template-columns:32px 1fr 42px; gap:8px }}
|
||
.tech-grid {{ grid-template-columns:1fr }}
|
||
.btns {{ flex-direction:column; align-items:center }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<div class="section header">
|
||
<h1>⚡ {title}</h1>
|
||
<p>Быстрый VPN с собственными серверами в Европе. Безлимитный трафик, VLESS + XTLS, никаких логов.</p>
|
||
<p class="sub">Подписка на основе подписки · Happ-совместимость · Мгновенная активация</p>
|
||
</div>
|
||
|
||
<div class="section why">
|
||
<h2>Почему {title}</h2>
|
||
<div class="why-grid">
|
||
<div class="w-card" style="--i:0"><div class="e">🚀</div><h4>Скорость</h4><p>Каналы 1–10 Gbit/s, собственные серверы, без посредников</p></div>
|
||
<div class="w-card" style="--i:1"><div class="e">🔒</div><h4>Приватность</h4><p>Zero-log политика — трафик не хранится и не анализируется</p></div>
|
||
<div class="w-card" style="--i:2"><div class="e">🌍</div><h4>Покрытие</h4><p>Серверы в Германии, Швеции и России — выбирай ближайший</p></div>
|
||
<div class="w-card" style="--i:3"><div class="e">💎</div><h4>Тарифы</h4><p>Бесплатный доступ к free-серверам или Premium от 150 ₽/мес</p></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section srv-section">
|
||
<h2>🖥 Состояние серверов</h2>
|
||
<div class="servers">{srv_cards}</div>
|
||
</div>
|
||
|
||
<div class="section tech">
|
||
<h2>🔧 Технические детали</h2>
|
||
<div class="tech-grid">
|
||
<div class="t-item"><span class="t-dot"></span>VLESS + XTLS Vision / Reality</div>
|
||
<div class="t-item"><span class="t-dot"></span>TCP, WebSocket, gRPC</div>
|
||
<div class="t-item"><span class="t-dot"></span>DDoS-защита на всех серверах</div>
|
||
<div class="t-item"><span class="t-dot"></span>Zero-log политика</div>
|
||
<div class="t-item"><span class="t-dot"></span>Поддержка Happ (iOS / Android / Desktop)</div>
|
||
<div class="t-item"><span class="t-dot"></span>Автообновление подписки каждые 12ч</div>
|
||
</div>
|
||
<div class="btns">
|
||
<a href="{da_url}" class="btn da">❤️ Поддержать проект</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer">© 2026 {title}</div>
|
||
|
||
</div>
|
||
</body>
|
||
</html>''')
|
||
|
||
|
||
async def fetch_servers_status() -> list:
|
||
results = []
|
||
now = time.time()
|
||
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": {}, "online": False, "uptime": ""}
|
||
if not url:
|
||
results.append(entry)
|
||
continue
|
||
try:
|
||
resp = await client.get(url)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
entry["online"] = True
|
||
entry["server_name"] = data.get("server_name", "")
|
||
entry["checks"] = data.get("checks", {})
|
||
|
||
name = srv["name"]
|
||
if name not in _first_seen:
|
||
_first_seen[name] = now
|
||
uptime_secs = now - _first_seen[name]
|
||
d = int(uptime_secs // 86400)
|
||
h = int((uptime_secs % 86400) // 3600)
|
||
m = int((uptime_secs % 3600) // 60)
|
||
parts = []
|
||
if d > 0: parts.append(f"{d}д")
|
||
parts.append(f"{h}ч")
|
||
parts.append(f"{m}м")
|
||
entry["uptime"] = " ".join(parts)
|
||
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") |