From 255caeb06989c3fb513d8a3f9d41041b3471050c Mon Sep 17 00:00:00 2001 From: SashegDev Date: Tue, 16 Jun 2026 21:23:03 +0000 Subject: [PATCH] fix: server health ping, rate limiting, security hardening, DA backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server health ping every 30s — dead servers excluded from all API calls, auto-recovered when back online - Fixed py3xui client.delete() argument order (inbound_id first, client_uuid second) - Rate limiting on /admin/login (max 10 attempts per 60s per IP) - XSS prevention: HTML-escape all user-controlled fields in templates - CSP headers on all HTML pages (CSP, X-Frame-Options, X-Content-Type-Options) - CORS restricted to configured host instead of wildcard - Secure + SameSite=Lax cookie flags for admin_token - Bind to 127.0.0.1 instead of 0.0.0.0 - Async locks for global state (last_donation_id) - Graceful shutdown: all background tasks cancelled on exit - DA polling exponential backoff: 1s→max 1h, immediate 1h on 401 --- README.md | 19 +- aggregator.py | 871 ++++++++++++++++++++++++++++---------------------- 2 files changed, 513 insertions(+), 377 deletions(-) diff --git a/README.md b/README.md index c14dbe2..c0fd64c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ aggregator.py ──→ FastAPI сервер - **ShortID ротация**: автоматическая смена shortId на серверах каждые N часов - **MOTD**: объявления из motd.txt или settings - **Health scoring**: взвешенная оценка здоровья сервера (CPU 30%, RAM 25%, Disk 20%, Net 15%, I/O 10%) +- **Server health ping**: фоновый пинг всех серверов каждые 30с — dead сервера автоматически исключаются из подписки, трафика, ротации shortid и админки +- **Auto-recovery**: при возвращении сервера в сеть он снова включается в работу без перезагрузки +- **Rate limiting**: не более 10 попыток входа в админку за 60с с одного IP +- **DA polling backoff**: при ошибках DonationAlerts API интервал удваивается до 1ч, при 401 — сразу 1ч ожидания +- **CSP + XSS protection**: Content-Security-Policy на всех HTML-страницах, экранирование user-контролируемых полей +- **Graceful shutdown**: все фоновые задачи корректно завершаются при остановке - **Trojan support**: создание клиентов на Trojan инбаундах (password вместо uuid) ## wdtt (Zern-BlackOut) Compatibility @@ -213,10 +219,13 @@ curl -X POST http://localhost:8000/admin/api/propagate/{server_name} -b "admin_t ## DonationAlerts -Polling-based (every ~2 seconds). Amount mapping from `settings.conf → tiers → prices`: +Polling-based с adaptive backoff. Amount mapping from `settings.conf → tiers → prices`: - Any configured amount → corresponding tier + days - Donor identified by `id` or `username` from donation message - `tariff_end_at` extends dynamically (MAX of current end, now + days) +- При ошибках API интервал удваивается (1 → 2 → 4 → ... → 3600с) +- При 401 Unauthorized — сразу 1ч ожидания (токен невалиден) +- После успешного ответа интервал сбрасывается к базовому ## Happ.su / WDTT Support @@ -228,10 +237,14 @@ Subscription response includes compatible headers: ## Tech -- **FastAPI** + uvicorn +- **FastAPI** + uvicorn (bind на `127.0.0.1` — за reverse proxy) - **SQLite** (users only) - **py3xui** — 3x-UI API client -- **httpx** — async HTTP for sub links +- **httpx** — async HTTP for sub links + server health pings - **qrcode** — QR generation - **Plus Jakarta Sans / JetBrains Mono** — UI typography - **Glassmorphism** — design system +- **CSP + Security headers** — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options на всех страницах +- **CORS** — ограничен до домена из конфига +- **Rate limiting** — in-memory на `/admin/login` +- **Server health** — фоновый пинг каждые 30с, автоисключение dead серверов diff --git a/aggregator.py b/aggregator.py index 52d8fa0..31a653e 100644 --- a/aggregator.py +++ b/aggregator.py @@ -15,7 +15,9 @@ import hashlib import secrets import sqlite3 import time +import html from datetime import datetime, timedelta +from collections import defaultdict from qrcode.image.styledpil import StyledPilImage from qrcode.image.styles.moduledrawers import SquareModuleDrawer from typing import List, Dict, Optional, Tuple @@ -47,12 +49,63 @@ servers = [] settings = {} last_donation_id = 0 +_global_lock = asyncio.Lock() +_donation_lock = asyncio.Lock() + +_ratelimit_attempts: Dict[str, list] = defaultdict(list) +RATELIMIT_MAX = 10 +RATELIMIT_WINDOW = 60 + +_background_tasks: List[asyncio.Task] = [] + # 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 +# Server health tracking +_server_health: Dict[str, bool] = {} +HEALTH_PING_INTERVAL = 30 + +async def _ping_server(srv: dict) -> bool: + url = srv.get("status_url", "") or srv.get("subscription_url", "") + if not url: + return True + try: + async with httpx.AsyncClient(timeout=5.0, verify=False) as client: + resp = await client.get(url) + return resp.status_code < 500 + except: + return False + +async def _server_health_loop(): + while True: + for srv in servers: + name = srv.get("name", "") + alive = await _ping_server(srv) + prev = _server_health.get(name) + if alive != prev: + _server_health[name] = alive + logger.info(f"Server {name} is now {'alive' if alive else 'dead'}") + await asyncio.sleep(HEALTH_PING_INTERVAL) + +def is_server_alive(name: str) -> bool: + return _server_health.get(name, True) + +def h(text) -> str: + if text is None: + return "" + return html.escape(str(text), quote=True) + +def is_ratelimited(ip: str) -> bool: + now = time.time() + _ratelimit_attempts[ip] = [t for t in _ratelimit_attempts[ip] if now - t < RATELIMIT_WINDOW] + if len(_ratelimit_attempts[ip]) >= RATELIMIT_MAX: + return True + _ratelimit_attempts[ip].append(now) + return False + def _get_cached(key: str, cache: dict, ttl: float): entry = cache.get(key) if entry and time.time() - entry[1] < ttl: @@ -83,20 +136,18 @@ def clear_cache(sub_id: str = None): _traffic_cache.clear() def get_wdtt_servers() -> List[dict]: - """Возвращает серверы с is_wdtt: true""" - return [s for s in servers if s.get("is_wdtt") and s.get("is_active")] + return [s for s in servers if s.get("is_wdtt") and s.get("is_active") and is_server_alive(s.get("name", ""))] async def _wdtt_api_call(server: dict, method: str, path: str, body: dict = None) -> dict: - """Вызов REST API wdtt-server""" api_key = server.get("wdtt_api_key", "") host = server.get("wdtt_host", "") if not api_key or not host: return {"error": "wdtt server not configured"} - + port = server.get("wdtt_api_port", "8080") url = f"http://{host}:{port}{path}" headers = {"X-API-Key": api_key, "Content-Type": "application/json"} - + try: async with httpx.AsyncClient(timeout=10.0, verify=False) as client: if method == "POST": @@ -105,7 +156,7 @@ async def _wdtt_api_call(server: dict, method: str, path: str, body: dict = None resp = await client.request("DELETE", url, json=body, headers=headers) else: resp = await client.get(url, headers=headers) - + if resp.status_code in (200, 201): return resp.json() else: @@ -119,25 +170,23 @@ async def _wdtt_api_call(server: dict, method: str, path: str, body: dict = None return {"error": str(e)[:100]} async def sync_wdtt_password(user: dict): - """Создаёт или обновляет wdtt-пароль для премиум пользователя""" wdtt_servers = get_wdtt_servers() if not wdtt_servers: return - + tier = user.get("tier", "free") remaining_days = get_remaining_days(user) - + conn = get_db() try: db_user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() if not db_user: return db_user = dict(db_user) - + expires_at = db_user.get("tariff_end_at", 0) current_password = db_user.get("wdtt_password") - - # Если не премиум — удаляем пароль + if tier != "paid" or remaining_days <= 0: if current_password: for srv in wdtt_servers: @@ -148,13 +197,11 @@ async def sync_wdtt_password(user: dict): conn.commit() logger.info(f"WDTT password removed for user {user['username']}") return - - # Берём первый wdtt-сервер + server = wdtt_servers[0] server_id = servers.index(server) - + if current_password: - # Обновляем expiry result = await _wdtt_api_call(server, "POST", "/api/passwords/update", {"password": current_password, "expires_at": expires_at}) if result.get("status") == "ok": @@ -165,7 +212,6 @@ async def sync_wdtt_password(user: dict): else: logger.warning(f"Failed to update WDTT password for {user['username']}: {result.get('error')}") else: - # Генерируем новый пароль new_password = secrets.token_urlsafe(24) result = await _wdtt_api_call(server, "POST", "/api/passwords/add", {"password": new_password, "expires_at": expires_at}) @@ -182,7 +228,6 @@ async def sync_wdtt_password(user: dict): conn.close() async def remove_wdtt_password(user: dict): - """Удаляет wdtt-пароль пользователя""" conn = get_db() try: db_user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() @@ -192,11 +237,11 @@ async def remove_wdtt_password(user: dict): password = db_user.get("wdtt_password") if not password: return - + for srv in get_wdtt_servers(): await _wdtt_api_call(srv, "DELETE", "/api/passwords/remove", {"password": password}) - + conn.execute("UPDATE users SET wdtt_password = NULL, wdtt_expires_at = 0, wdtt_server_id = NULL WHERE id = ?", (user["id"],)) conn.commit() @@ -294,13 +339,13 @@ def render_template(name: str, **kwargs) -> str: path = os.path.join(WEB_DIR, name) try: with open(path, "r", encoding="utf-8") as f: - html = f.read() + html_content = f.read() except FileNotFoundError: logger.error(f"Template not found: {path}") return "Template error" for k, v in kwargs.items(): - html = html.replace(f"{{%{k}%}}", str(v)) - return html + html_content = html_content.replace(f"{{%{k}%}}", str(v)) + return html_content def get_flag_emoji(country_code: str) -> str: if not country_code or len(country_code) < 2: @@ -336,7 +381,6 @@ def get_motd() -> str: return f.read().strip() def get_random_hashes(count: int = 4) -> List[str]: - """Читает до `count` случайных хешей из hashes.txt""" if not os.path.exists(HASHES_FILE): return [] with open(HASHES_FILE, encoding="utf-8") as f: @@ -416,8 +460,10 @@ def get_traffic_stats(sub_id: str) -> dict: total_up = 0 total_down = 0 by_server = {} - + for srv in servers: + if not is_server_alive(srv.get("name", "")): + continue srv_up = 0 srv_down = 0 for inbound in srv.get("inbounds", []): @@ -425,14 +471,14 @@ def get_traffic_stats(sub_id: str) -> dict: 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: @@ -441,56 +487,61 @@ def get_traffic_stats(sub_id: str) -> dict: srv_up += client.up or 0 srv_down += client.down or 0 except: - pass - + continue + 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}") + try: + 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'([a-z]+://[^\s\n]+)', decoded) + if links: + return links + except: + pass + return re.findall(r'([a-z]+://[^\s\n]+)', content) + except Exception as e: + logger.warning(f"Server unreachable {url}: {e}") return [] - if resp.status_code == 200: - content = resp.text.strip() - try: - decoded = base64.b64decode(content).decode('utf-8') - links = re.findall(r'([a-z]+://[^\s\n]+)', decoded) - if links: - return links - except: - pass - return re.findall(r'([a-z]+://[^\s\n]+)', content) - except Exception as e: - logger.error(f"Fetch error: {e}") + except Exception as e: + logger.warning(f"Fetch error for {url}: {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: + if not is_server_alive(srv.get("name", "")): + continue 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: @@ -506,15 +557,15 @@ def find_user_on_servers(sub_id: str) -> Optional[str]: 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}") - + logger.warning(f"Skipping unavailable server {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: @@ -528,17 +579,17 @@ VALUES (?, ?, 'free', 0, 0, 0, 0, 1) logger.info(f"User migrated: {username}") except sqlite3.IntegrityError: pass - + if not user: return None, "free" - + user = dict(user) - + if user['tier'] == 'paid' and get_remaining_days(user) <= 0: conn.execute("UPDATE users SET tier = 'free' WHERE id = ?", (user['id'],)) conn.commit() user['tier'] = 'free' - + return user, user['tier'] finally: conn.close() @@ -548,6 +599,8 @@ def get_servers_for_tier(tier: str) -> List[dict]: for srv in servers: if not srv.get("is_active"): continue + if not is_server_alive(srv.get("name", "")): + continue result.append(srv) return result @@ -558,7 +611,7 @@ def deduplicate_inbounds(servers_list: List[dict], tier: str) -> List[Tuple[dict 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) @@ -571,37 +624,222 @@ async def lifespan(app): if ap_cfg.get("enabled", False): interval = max(10, ap_cfg.get("interval_minutes", 60)) * 60 logger.info(f"Auto-propagate enabled, interval={interval}s") - loop = asyncio.get_event_loop() - async def _run(): + async def _run_auto_propagate(): while True: for srv in servers: if not srv.get("is_active", True): continue + if not is_server_alive(srv.get("name", "")): + continue try: - result = await loop.run_in_executor(None, propagate_server_sync, srv["name"]) + result = await asyncio.get_event_loop().run_in_executor(None, propagate_server_sync, srv["name"]) if result.get("added", 0) or result.get("failed", 0): logger.info(f"Auto-propagate {srv['name']}: +{result.get('added',0)} added, {result.get('failed',0)} failed, {result.get('skipped',0)} skipped") except: logger.exception(f"Auto-propagate error on {srv.get('name','?')}") await asyncio.sleep(interval) - task = asyncio.create_task(_run()) + task = asyncio.create_task(_run_auto_propagate()) + _background_tasks.append(task) + + async def _run_rotate_shortids(): + while True: + try: + rotation_hours = settings.get("shortid_rotation_hours", 11) + await asyncio.sleep(rotation_hours * 3600) + for srv in servers: + if not is_server_alive(srv.get("name", "")): + continue + 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) + + task2 = asyncio.create_task(_run_rotate_shortids()) + _background_tasks.append(task2) + + async def _run_poll_donationalerts(): + global last_donation_id + backoff = 1 + max_backoff = 3600 + 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) + sleep_time = max(interval * 60, backoff) + await asyncio.sleep(sleep_time) + + 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 == 401: + logger.error(f"DA API returned 401 Unauthorized — token is invalid. Backing off for {max_backoff}s") + backoff = max_backoff + continue + elif resp.status_code != 200: + logger.error(f"DA API error: HTTP {resp.status_code}, backing off") + backoff = min(backoff * 2, max_backoff) + continue + + backoff = 1 + + data = resp.json() + donations = data.get("data", []) + + for donation in reversed(donations): + donation_id = donation.get("id") + async with _donation_lock: + if donation_id <= last_donation_id: + continue + last_donation_id = donation_id + + 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)") + 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: + now_ts = int(time.time()) + current_end = user.get("tariff_end_at", 0) + if current_end > now_ts: + new_end = current_end + days * 86400 + else: + new_end = now_ts + days * 86400 + + conn.execute(""" + UPDATE users SET + tier = ?, + tariff_days_bought = tariff_days_bought + ?, + tariff_end_at = ?, + total_paid_rubles = total_paid_rubles + ? + WHERE id = ? + """, (tier, days, new_end, amount, user["id"])) + conn.commit() + + sub_id = user["subscription_id"] + update_3xui_expiry(sub_id, days) + + updated = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() + if updated: + asyncio.create_task(sync_wdtt_password(dict(updated))) + + logger.info(f"VPN payment: {username} paid {amount} RUB, tier={tier}, +{days} days") + finally: + conn.close() + + except Exception as e: + logger.error(f"DonationAlerts polling error: {e}") + await asyncio.sleep(300) + + task3 = asyncio.create_task(_run_poll_donationalerts()) + _background_tasks.append(task3) + + task4 = asyncio.create_task(_server_health_loop()) + _background_tasks.append(task4) + yield - if ap_cfg.get("enabled", False): - task.cancel() + + for t in _background_tasks: + t.cancel() + await asyncio.gather(*_background_tasks, return_exceptions=True) app = FastAPI(title="ZernProxy Manager", docs_url=None, redoc_url=None, lifespan=lifespan) -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +allowed_host = settings.get("general", {}).get("host", "") +app.add_middleware( + CORSMiddleware, + allow_origins=[f"https://{allowed_host}", f"http://{allowed_host}"] if allowed_host else [], + allow_credentials=True, + allow_methods=["GET", "POST"], + allow_headers=["Content-Type", "Authorization", "X-Webhook-Secret"], +) app.mount("/web", StaticFiles(directory=WEB_DIR), name="web") ADMIN_USER = settings.get("admin", {}).get("username", "admin") ADMIN_PASS = settings.get("admin", {}).get("password", "") +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + ctype = response.headers.get("content-type", "") + if "text/html" in ctype: + response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:;" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + return response + def check_admin(request: Request) -> bool: if not ADMIN_PASS: return True token = request.cookies.get("admin_token", "") - if token == ADMIN_PASS: - return True + if token: + if token == ADMIN_PASS: + return True auth = request.headers.get("Authorization", "") if auth.startswith("Basic "): try: @@ -615,94 +853,103 @@ def check_admin(request: Request) -> bool: @app.get("/admin/login") async def admin_login_page(): - return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg="")) + return HTMLResponse(content=render_template("admin_login.html", title=h(settings.get("general", {}).get("title", "ZernProxy")), error_msg="")) @app.post("/admin/login") async def admin_login(request: Request): + ip = request.client.host if request.client else "unknown" + if is_ratelimited(ip): + return HTMLResponse( + content=render_template("admin_login.html", title=h(settings.get("general", {}).get("title", "ZernProxy")), + error_msg='
Слишком много попыток. Подождите.
'), + status_code=429) + data = await request.form() if data.get("username") == ADMIN_USER and data.get("password") == ADMIN_PASS: resp = RedirectResponse(url="/admin/users", status_code=303) - resp.set_cookie(key="admin_token", value=ADMIN_PASS, max_age=86400 * 7, httponly=True) + resp.set_cookie(key="admin_token", value=ADMIN_PASS, max_age=86400 * 7, httponly=True, secure=True, samesite="lax") return resp - return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg='
Неверный пароль
'), status_code=401) + return HTMLResponse( + content=render_template("admin_login.html", title=h(settings.get("general", {}).get("title", "ZernProxy")), + error_msg='
Неверный пароль
'), + 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) - + fetched = await fetch_vless_links(url) + if fetched: + server_links[srv_name] = fetched + links = server_links.get(srv_name, []) + if not links: + continue + 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}") @@ -710,7 +957,7 @@ async def get_subscription(request: Request, subscription_id: str, format: str = lines.append("#hide-settings: 1") if support_url: lines.append(f"#support-url: {support_url}") - + expire_ts = user.get("tariff_end_at", 0) or 0 rem_days = get_remaining_days(user) if rem_days > 0: @@ -719,25 +966,25 @@ async def get_subscription(request: Request, subscription_id: str, format: str = 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), @@ -754,8 +1001,7 @@ async def get_subscription(request: Request, subscription_id: str, format: str = headers["Sub-Expire"] = "1" if support_url: headers["Sub-Expire-Button-Link"] = support_url - - # WDTT-специфичные заголовки для клиента ZernProxy + is_wdtt_client = request.query_params.get("client") == "wdtt" or "WDTT" in request.headers.get("User-Agent", "") if is_wdtt_client or user.get("wdtt_password"): wdtt_servers = get_wdtt_servers() @@ -770,20 +1016,19 @@ async def get_subscription(request: Request, subscription_id: str, format: str = "country": s.get("country", ""), }) headers["X-WDTT-Servers"] = json.dumps(server_list) - + if user.get("wdtt_password"): headers["X-WDTT-Credentials"] = json.dumps({ "password": user["wdtt_password"], "expires_at": user.get("wdtt_expires_at", 0), "server_id": user.get("wdtt_server_id", 0), }) - - # ZernHash: случайные хеши для клиента + if is_wdtt_client or "ZernHash" in request.headers.get("User-Agent", ""): hashes = get_random_hashes(4) if hashes: headers["X-WDTT-Hashes"] = json.dumps(hashes) - + 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) @@ -792,30 +1037,30 @@ 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'Logo' if logo_base64 else '
' - announcement_html = f'
{announcement}
' if announcement else '' - + announcement_html = f'
{h(announcement)}
' if announcement else '' + tier_color = "#4CAF50" if tier == "paid" else "#757575" tier_name = tier_config.get("name", "Free") - tier_badge = f'{tier_name}' - + tier_badge = f'{h(tier_name)}' + days_remaining = get_remaining_days(user) days_info = f"

⏳ Осталось дней: {days_remaining}

" if tier == "paid" and days_remaining > 0 else "" - + traffic = get_cached_traffic(subscription_id) if not traffic: traffic = get_traffic_stats(subscription_id) @@ -823,58 +1068,58 @@ async def get_web_page(subscription_id: str): 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"

📊 Лимит: {traffic_limit_str}

⬆️ {format_bytes(traffic['total_up'])} | ⬇️ {format_bytes(traffic['total_down'])}

" - + 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'
{srv_name}: ⬆ {format_bytes(data["up"])} ⬇ {format_bytes(data["down"])}
' - + traffic_details += f'
{h(srv_name)}: ⬆ {format_bytes(data["up"])} ⬇ {format_bytes(data["down"])}
' + info_html = f'
{days_info}{traffic_info}{traffic_details}
' if days_info or traffic_info or traffic_details else '' - - servers_html = "".join(f'{get_flag_emoji(srv.get("country", ""))} {srv["name"].upper()}' for srv in servers_for_tier) - + + servers_html = "".join(f'{get_flag_emoji(srv.get("country", ""))} {h(srv["name"].upper())}' for srv in servers_for_tier) + support_btn = "" if da_config.get("enabled"): da_url = da_config.get("url", "#") support_btn = f''' - + Поддержать проект ''' - + html = render_template("sub.html", - title=title, logo_html=logo_html, tier_badge=tier_badge, + title=h(title), logo_html=logo_html, tier_badge=tier_badge, announcement_html=announcement_html, info_html=info_html, servers_html=servers_html, qr_base64=qr_base64, - sub_url=sub_url, support_btn=support_btn) + sub_url=h(sub_url), support_btn=support_btn) return HTMLResponse(content=html) @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: @@ -886,21 +1131,21 @@ async def webhook_donationalerts(request: Request): 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", {}) @@ -911,15 +1156,15 @@ async def webhook_donationalerts(request: Request): break if tier: break - + if not tier: return JSONResponse({"status": "ignored", "reason": "amount_not_recognized"}) - + conn = get_db() try: now_ts = int(time.time()) conn.execute(""" - UPDATE users SET + UPDATE users SET tier = ?, tariff_days_bought = tariff_days_bought + ?, tariff_end_at = MAX(COALESCE(tariff_end_at, 0), ?) + ? * 86400, @@ -927,63 +1172,63 @@ async def webhook_donationalerts(request: Request): WHERE id = ? """, (tier, days, now_ts, days, amount, user["id"])) conn.commit() - + updated = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() if updated: asyncio.create_task(sync_wdtt_password(dict(updated))) 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=render_template("admin_login.html", title="Вход", error_msg="")) + return HTMLResponse(content=render_template("admin_login.html", title=h(settings.get("general", {}).get("title", "ZernProxy")), error_msg="")) 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", {}) - + title = settings.get("general", {}).get("title", "ZernProxy") tier_options = "".join( - f'' + f'' for k, v in settings.get("tiers", {}).items()) users_rows = "" for u in users: traffic = f'{u["traffic_limit_gb"]} GB' if u['traffic_limit_gb'] > 0 else '∞' active = '✓' if u['is_active'] else '✗' - tier_display = u['tier'].upper() + tier_display = h(u['tier'].upper()) rem_days = get_remaining_days(u) users_rows += f''' {u['id']} - {u['username']} - {u['subscription_id']} - {tier_display} + {h(u['username'])} + {h(u['subscription_id'])} + {tier_display} {rem_days} {u['total_paid_rubles']}₽ {traffic} {active} {u['created_at'][:10]} - - + + ''' html = render_template("admin_users.html", - title=title, user_count=len(users), users_rows=users_rows, + title=h(title), user_count=len(users), users_rows=users_rows, tier_options=tier_options) return HTMLResponse(content=html) @app.get("/admin/dashboard") async def admin_dashboard(request: Request): if not check_admin(request): - return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg="")) - + return HTMLResponse(content=render_template("admin_login.html", title=h(settings.get("general", {}).get("title", "ZernProxy")), error_msg="")) + conn = get_db() try: total_users = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"] @@ -994,9 +1239,11 @@ async def admin_dashboard(request: Request): 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: + if not is_server_alive(srv.get("name", "")): + continue 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) @@ -1005,13 +1252,13 @@ async def admin_dashboard(request: Request): online_count += len(online) except: pass - + title = settings.get("general", {}).get("title", "ZernProxy") servers_rows = "".join( - f'
{get_flag_emoji(s.get("country",""))} {s["name"].upper()}{len(s.get("inbounds",[]))} inbound
' + f'
{get_flag_emoji(s.get("country",""))} {h(s["name"].upper())}{len(s.get("inbounds",[]))} inbound
' for s in servers) html = render_template("admin_dashboard.html", - title=title, total_users=total_users, free_users=free_users, + title=h(title), total_users=total_users, free_users=free_users, paid_users=paid_users, test_users=test_users, online_count=online_count, total_revenue=total_revenue, server_count=len(servers), @@ -1023,9 +1270,9 @@ async def admin_dashboard(request: Request): async def home_page(): title = settings.get("general", {}).get("title", "ZernProxy") da_url = settings.get("payments", {}).get("donationalerts", {}).get("url", "") - + statuses = await fetch_servers_status() - + srv_cards = "" for i, s in enumerate(statuses): chk = s.get("checks", {}) @@ -1035,7 +1282,7 @@ async def home_page(): srv_name = s.get("server_name", s["name"].upper()) delay = 0.45 + i * 0.15 online = s.get("online", False) - + srv_inbounds = [] for srv_cfg in servers: if srv_cfg["name"] == s["name"]: @@ -1047,14 +1294,14 @@ async def home_page(): else: has_free = True has_paid = True - + if has_free and has_paid: badge = 'Free+Premium' elif has_free: badge = 'Free' else: badge = 'Premium' - + if online: _, hc, hl = calc_health_score(chk) s1 = cpu if cpu is not None else 0 @@ -1065,21 +1312,21 @@ async def home_page(): hc = "#475569" hl = "Offline" stats = '
Offline
' - + inbound_cards = "" for ib in srv_inbounds: ib_name = ib.get("name", "unknown") ib_type = ib.get("network", ib.get("flow", "")) - inbound_cards += f'
{ib_name}{ib_type}
\n' - + inbound_cards += f'
{h(ib_name)}{h(ib_type)}
\n' + ru_note = "" if s.get("country", "").lower() == "ru" and online: ru_note = '
★ Нет рекламы в YouTube · Адаптирован к будущему ограничению 15 ГБ зарубежного трафика через Proxy
' - + srv_cards += f'''
- {get_flag_emoji(s.get("country",""))} {srv_name} {badge} + {get_flag_emoji(s.get("country",""))} {h(srv_name)} {badge} {hl}
@@ -1087,8 +1334,8 @@ async def home_page():
{inbound_cards}
{ru_note}
''' - - return HTMLResponse(content=render_template("home.html", title=title, da_url=da_url, srv_cards=srv_cards)) + + return HTMLResponse(content=render_template("home.html", title=h(title), da_url=h(da_url), srv_cards=srv_cards)) async def fetch_servers_status() -> list: @@ -1110,6 +1357,7 @@ async def fetch_servers_status() -> list: else: entry["checks"]["error"] = {"value": f"HTTP {resp.status_code}"} except Exception as e: + logger.warning(f"Status check failed for {srv.get('name','?')}: {e}") entry["checks"]["error"] = {"value": str(e)[:50]} results.append(entry) return results @@ -1122,25 +1370,24 @@ async def tg_webhook(): 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 if inbound.get("protocol") == "trojan": client = Client( @@ -1163,20 +1410,20 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in 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) + api.client.delete(inbound_id, existing.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 @@ -1184,19 +1431,18 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in 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: @@ -1205,9 +1451,9 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict: 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]} @@ -1252,14 +1498,16 @@ def propagate_server_sync(server_name: str) -> dict: target_srv = next((s for s in servers if s["name"] == server_name), None) if not target_srv: return {"error": "Server not found"} + if not is_server_alive(server_name): + return {"error": "Target server is dead"} conn = get_db() try: users = conn.execute("SELECT username, subscription_id, traffic_limit_gb, is_active FROM users WHERE is_active = 1").fetchall() finally: conn.close() - other_servers = [s for s in servers if s.get("is_active", True) and s["name"] != server_name] + other_servers = [s for s in servers if s.get("is_active", True) and is_server_alive(s.get("name", "")) and s["name"] != server_name] threshold = max(1, len(other_servers) // 2 + 1) - + target_ib_ids = fetch_server_sub_ids(target_srv) other_ids = {} for s in other_servers: @@ -1268,24 +1516,24 @@ def propagate_server_sync(server_name: str) -> dict: for ids in ib_dict.values(): flat.update(ids) other_ids[s["name"]] = flat - + total = len(users) added = skipped = failed = 0 inbound_stats = {} for ib in target_srv.get("inbounds", []): inbound_stats[ib.get("name", f"id_{ib.get('id','?')}")] = 0 - + results = [] for u in users: sub_id = u["subscription_id"] username = u["username"] - + count = sum(1 for s in other_servers if sub_id in other_ids.get(s["name"], set())) if count < threshold: skipped += 1 results.append({"username": username, "skipped": True, "servers": count, "total": len(other_servers)}) continue - + user_added = False for inbound in target_srv.get("inbounds", []): ib_id = inbound.get("id") @@ -1301,11 +1549,11 @@ def propagate_server_sync(server_name: str) -> dict: else: failed += 1 results.append({"username": username, "inbound": ib_name, "error": r.get("error", "")}) - + if user_added: added += 1 results.append({"username": username, "added": True}) - + already = sum(1 for u in users if u["subscription_id"] in {sid for ids in target_ib_ids.values() for sid in ids}) return {"server": server_name, "total": total, "already_on_server": already, "added": added, "skipped": skipped, "failed": failed, @@ -1328,15 +1576,17 @@ async def create_user(request: Request, data: dict): 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: + if not is_server_alive(srv.get("name", "")): + continue srv_result = {"server": srv["name"], "inbounds": []} for inbound in srv.get("inbounds", []): result = create_3xui_client(username, sub_id, inbound, traffic_gb) @@ -1346,7 +1596,7 @@ async def create_user(request: Request, data: dict): "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) @@ -1358,10 +1608,10 @@ async def create_user(request: Request, data: dict): 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, @@ -1375,17 +1625,16 @@ async def create_user(request: Request, data: dict): 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) tariff_end_at = int(time.time()) + tariff_days_remaining * 86400 if tariff_days_remaining > 0 else 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(""" @@ -1393,18 +1642,17 @@ async def update_user(request: Request, data: dict): WHERE id = ? """, (tier, tariff_days_remaining, tariff_end_at, 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() - + if updated: updated = dict(updated) updated["tier"] = tier clear_cache(updated["subscription_id"]) asyncio.create_task(sync_wdtt_password(updated)) - + return JSONResponse({"status": "ok"}) @app.post("/admin/api/users/delete") @@ -1413,82 +1661,48 @@ async def delete_user(request: Request, data: dict): return JSONResponse({"error": "Unauthorized"}, status_code=401) user_id = data.get("id") username = data.get("username", "") - + conn = get_db() try: user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() if not user: return JSONResponse({"error": "user not found"}, status_code=404) - + user = dict(user) sub_id = user["subscription_id"] - + for srv in servers: + if not is_server_alive(srv.get("name", "")): + continue for inbound in srv.get("inbounds", []): - result = delete_3xui_client(username, sub_id, inbound) - + delete_3xui_client(username, sub_id, inbound) + asyncio.create_task(remove_wdtt_password(user)) - + 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: @@ -1499,119 +1713,7 @@ def update_3xui_expiry(sub_id: str, days: int): 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: - now_ts = int(time.time()) - current_end = user.get("tariff_end_at", 0) - if current_end > now_ts: - new_end = current_end + days * 86400 - else: - new_end = now_ts + days * 86400 - - conn.execute(""" - UPDATE users SET - tier = ?, - tariff_days_bought = tariff_days_bought + ?, - tariff_end_at = ?, - total_paid_rubles = total_paid_rubles + ? - WHERE id = ? - """, (tier, days, new_end, amount, user["id"])) - conn.commit() - - sub_id = user["subscription_id"] - update_3xui_expiry(sub_id, days) - - updated = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() - if updated: - asyncio.create_task(sync_wdtt_password(dict(updated))) - - 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) + logger.warning(f"Skipping unavailable server {srv['name']}/{inbound['name']} for expiry update: {e}") @app.get("/health") async def health(): @@ -1625,6 +1727,31 @@ async def reload_configs(request: Request): clear_cache() return {"status": "ok"} +async def rotate_shortids(): + rotation_hours = settings.get("shortid_rotation_hours", 11) + for srv in servers: + if not is_server_alive(srv.get("name", "")): + continue + 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.warning(f"ShortID rotation skipped for {srv['name']}/{inbound['name']}: {e}") + @app.get("/admin/api/rotate-shortids") async def manual_rotate(request: Request): if not check_admin(request): @@ -1633,8 +1760,4 @@ async def manual_rotate(request: Request): 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") + uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")