fix: server health ping, rate limiting, security hardening, DA backoff

- 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
This commit is contained in:
SashegDev
2026-06-16 21:23:03 +00:00
parent d1f018dba1
commit 255caeb069
2 changed files with 513 additions and 377 deletions
+16 -3
View File
@@ -35,6 +35,12 @@ aggregator.py ──→ FastAPI сервер
- **ShortID ротация**: автоматическая смена shortId на серверах каждые N часов - **ShortID ротация**: автоматическая смена shortId на серверах каждые N часов
- **MOTD**: объявления из motd.txt или settings - **MOTD**: объявления из motd.txt или settings
- **Health scoring**: взвешенная оценка здоровья сервера (CPU 30%, RAM 25%, Disk 20%, Net 15%, I/O 10%) - **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) - **Trojan support**: создание клиентов на Trojan инбаундах (password вместо uuid)
## wdtt (Zern-BlackOut) Compatibility ## wdtt (Zern-BlackOut) Compatibility
@@ -213,10 +219,13 @@ curl -X POST http://localhost:8000/admin/api/propagate/{server_name} -b "admin_t
## DonationAlerts ## 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 - Any configured amount → corresponding tier + days
- Donor identified by `id` or `username` from donation message - Donor identified by `id` or `username` from donation message
- `tariff_end_at` extends dynamically (MAX of current end, now + days) - `tariff_end_at` extends dynamically (MAX of current end, now + days)
- При ошибках API интервал удваивается (1 → 2 → 4 → ... → 3600с)
- При 401 Unauthorized — сразу 1ч ожидания (токен невалиден)
- После успешного ответа интервал сбрасывается к базовому
## Happ.su / WDTT Support ## Happ.su / WDTT Support
@@ -228,10 +237,14 @@ Subscription response includes compatible headers:
## Tech ## Tech
- **FastAPI** + uvicorn - **FastAPI** + uvicorn (bind на `127.0.0.1` — за reverse proxy)
- **SQLite** (users only) - **SQLite** (users only)
- **py3xui** — 3x-UI API client - **py3xui** — 3x-UI API client
- **httpx** — async HTTP for sub links - **httpx** — async HTTP for sub links + server health pings
- **qrcode** — QR generation - **qrcode** — QR generation
- **Plus Jakarta Sans / JetBrains Mono** — UI typography - **Plus Jakarta Sans / JetBrains Mono** — UI typography
- **Glassmorphism** — design system - **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 серверов
+342 -219
View File
@@ -15,7 +15,9 @@ import hashlib
import secrets import secrets
import sqlite3 import sqlite3
import time import time
import html
from datetime import datetime, timedelta from datetime import datetime, timedelta
from collections import defaultdict
from qrcode.image.styledpil import StyledPilImage from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.moduledrawers import SquareModuleDrawer from qrcode.image.styles.moduledrawers import SquareModuleDrawer
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
@@ -47,12 +49,63 @@ servers = []
settings = {} settings = {}
last_donation_id = 0 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 # In-memory cache
_links_cache: Dict[str, Tuple[List[str], float]] = {} _links_cache: Dict[str, Tuple[List[str], float]] = {}
_traffic_cache: Dict[str, Tuple[Dict, float]] = {} _traffic_cache: Dict[str, Tuple[Dict, float]] = {}
CACHE_TTL_TRAFFIC = 120 CACHE_TTL_TRAFFIC = 120
CACHE_TTL_LINKS = 86400 * 7 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): def _get_cached(key: str, cache: dict, ttl: float):
entry = cache.get(key) entry = cache.get(key)
if entry and time.time() - entry[1] < ttl: if entry and time.time() - entry[1] < ttl:
@@ -83,11 +136,9 @@ def clear_cache(sub_id: str = None):
_traffic_cache.clear() _traffic_cache.clear()
def get_wdtt_servers() -> List[dict]: 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") and is_server_alive(s.get("name", ""))]
return [s for s in servers if s.get("is_wdtt") and s.get("is_active")]
async def _wdtt_api_call(server: dict, method: str, path: str, body: dict = None) -> dict: 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", "") api_key = server.get("wdtt_api_key", "")
host = server.get("wdtt_host", "") host = server.get("wdtt_host", "")
if not api_key or not host: if not api_key or not host:
@@ -119,7 +170,6 @@ async def _wdtt_api_call(server: dict, method: str, path: str, body: dict = None
return {"error": str(e)[:100]} return {"error": str(e)[:100]}
async def sync_wdtt_password(user: dict): async def sync_wdtt_password(user: dict):
"""Создаёт или обновляет wdtt-пароль для премиум пользователя"""
wdtt_servers = get_wdtt_servers() wdtt_servers = get_wdtt_servers()
if not wdtt_servers: if not wdtt_servers:
return return
@@ -137,7 +187,6 @@ async def sync_wdtt_password(user: dict):
expires_at = db_user.get("tariff_end_at", 0) expires_at = db_user.get("tariff_end_at", 0)
current_password = db_user.get("wdtt_password") current_password = db_user.get("wdtt_password")
# Если не премиум — удаляем пароль
if tier != "paid" or remaining_days <= 0: if tier != "paid" or remaining_days <= 0:
if current_password: if current_password:
for srv in wdtt_servers: for srv in wdtt_servers:
@@ -149,12 +198,10 @@ async def sync_wdtt_password(user: dict):
logger.info(f"WDTT password removed for user {user['username']}") logger.info(f"WDTT password removed for user {user['username']}")
return return
# Берём первый wdtt-сервер
server = wdtt_servers[0] server = wdtt_servers[0]
server_id = servers.index(server) server_id = servers.index(server)
if current_password: if current_password:
# Обновляем expiry
result = await _wdtt_api_call(server, "POST", "/api/passwords/update", result = await _wdtt_api_call(server, "POST", "/api/passwords/update",
{"password": current_password, "expires_at": expires_at}) {"password": current_password, "expires_at": expires_at})
if result.get("status") == "ok": if result.get("status") == "ok":
@@ -165,7 +212,6 @@ async def sync_wdtt_password(user: dict):
else: else:
logger.warning(f"Failed to update WDTT password for {user['username']}: {result.get('error')}") logger.warning(f"Failed to update WDTT password for {user['username']}: {result.get('error')}")
else: else:
# Генерируем новый пароль
new_password = secrets.token_urlsafe(24) new_password = secrets.token_urlsafe(24)
result = await _wdtt_api_call(server, "POST", "/api/passwords/add", result = await _wdtt_api_call(server, "POST", "/api/passwords/add",
{"password": new_password, "expires_at": expires_at}) {"password": new_password, "expires_at": expires_at})
@@ -182,7 +228,6 @@ async def sync_wdtt_password(user: dict):
conn.close() conn.close()
async def remove_wdtt_password(user: dict): async def remove_wdtt_password(user: dict):
"""Удаляет wdtt-пароль пользователя"""
conn = get_db() conn = get_db()
try: try:
db_user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() db_user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
@@ -294,13 +339,13 @@ def render_template(name: str, **kwargs) -> str:
path = os.path.join(WEB_DIR, name) path = os.path.join(WEB_DIR, name)
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
html = f.read() html_content = f.read()
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Template not found: {path}") logger.error(f"Template not found: {path}")
return "Template error" return "Template error"
for k, v in kwargs.items(): for k, v in kwargs.items():
html = html.replace(f"{{%{k}%}}", str(v)) html_content = html_content.replace(f"{{%{k}%}}", str(v))
return html return html_content
def get_flag_emoji(country_code: str) -> str: def get_flag_emoji(country_code: str) -> str:
if not country_code or len(country_code) < 2: if not country_code or len(country_code) < 2:
@@ -336,7 +381,6 @@ def get_motd() -> str:
return f.read().strip() return f.read().strip()
def get_random_hashes(count: int = 4) -> List[str]: def get_random_hashes(count: int = 4) -> List[str]:
"""Читает до `count` случайных хешей из hashes.txt"""
if not os.path.exists(HASHES_FILE): if not os.path.exists(HASHES_FILE):
return [] return []
with open(HASHES_FILE, encoding="utf-8") as f: with open(HASHES_FILE, encoding="utf-8") as f:
@@ -418,6 +462,8 @@ def get_traffic_stats(sub_id: str) -> dict:
by_server = {} by_server = {}
for srv in servers: for srv in servers:
if not is_server_alive(srv.get("name", "")):
continue
srv_up = 0 srv_up = 0
srv_down = 0 srv_down = 0
for inbound in srv.get("inbounds", []): for inbound in srv.get("inbounds", []):
@@ -441,7 +487,7 @@ def get_traffic_stats(sub_id: str) -> dict:
srv_up += client.up or 0 srv_up += client.up or 0
srv_down += client.down or 0 srv_down += client.down or 0
except: except:
pass continue
if srv_up or srv_down: if srv_up or srv_down:
by_server[srv["name"]] = {"up": srv_up, "down": srv_down} by_server[srv["name"]] = {"up": srv_up, "down": srv_down}
@@ -454,6 +500,7 @@ def generate_sub_id(length: int = 16) -> str:
return ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(length)) return ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(length))
async def fetch_vless_links(url: str) -> List[str]: async def fetch_vless_links(url: str) -> List[str]:
try:
async with httpx.AsyncClient(verify=False, timeout=10.0, follow_redirects=True) as client: async with httpx.AsyncClient(verify=False, timeout=10.0, follow_redirects=True) as client:
try: try:
resp = await client.get(url) resp = await client.get(url)
@@ -471,13 +518,17 @@ async def fetch_vless_links(url: str) -> List[str]:
pass pass
return re.findall(r'([a-z]+://[^\s\n]+)', content) return re.findall(r'([a-z]+://[^\s\n]+)', content)
except Exception as e: except Exception as e:
logger.error(f"Fetch error: {e}") logger.warning(f"Server unreachable {url}: {e}")
return []
except Exception as e:
logger.warning(f"Fetch error for {url}: {e}")
return [] return []
def find_user_on_servers(sub_id: str) -> Optional[str]: def find_user_on_servers(sub_id: str) -> Optional[str]:
"""Ищет юзера на 3x-UI серверах по subId, возвращает username"""
logger.info(f"Looking for sub_id: {sub_id}") logger.info(f"Looking for sub_id: {sub_id}")
for srv in servers: for srv in servers:
if not is_server_alive(srv.get("name", "")):
continue
for inbound in srv.get("inbounds", []): for inbound in srv.get("inbounds", []):
api_host = inbound.get("api_host") api_host = inbound.get("api_host")
api_user = inbound.get("api_user") api_user = inbound.get("api_user")
@@ -506,7 +557,7 @@ def find_user_on_servers(sub_id: str) -> Optional[str]:
logger.info(f"Migrated user: {username} (found on {srv['name']}/{inbound['name']})") logger.info(f"Migrated user: {username} (found on {srv['name']}/{inbound['name']})")
return username return username
except Exception as e: 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 return None
@@ -548,6 +599,8 @@ def get_servers_for_tier(tier: str) -> List[dict]:
for srv in servers: for srv in servers:
if not srv.get("is_active"): if not srv.get("is_active"):
continue continue
if not is_server_alive(srv.get("name", "")):
continue
result.append(srv) result.append(srv)
return result return result
@@ -571,35 +624,220 @@ async def lifespan(app):
if ap_cfg.get("enabled", False): if ap_cfg.get("enabled", False):
interval = max(10, ap_cfg.get("interval_minutes", 60)) * 60 interval = max(10, ap_cfg.get("interval_minutes", 60)) * 60
logger.info(f"Auto-propagate enabled, interval={interval}s") logger.info(f"Auto-propagate enabled, interval={interval}s")
loop = asyncio.get_event_loop() async def _run_auto_propagate():
async def _run():
while True: while True:
for srv in servers: for srv in servers:
if not srv.get("is_active", True): if not srv.get("is_active", True):
continue continue
if not is_server_alive(srv.get("name", "")):
continue
try: 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): 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") logger.info(f"Auto-propagate {srv['name']}: +{result.get('added',0)} added, {result.get('failed',0)} failed, {result.get('skipped',0)} skipped")
except: except:
logger.exception(f"Auto-propagate error on {srv.get('name','?')}") logger.exception(f"Auto-propagate error on {srv.get('name','?')}")
await asyncio.sleep(interval) 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 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 = 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") app.mount("/web", StaticFiles(directory=WEB_DIR), name="web")
ADMIN_USER = settings.get("admin", {}).get("username", "admin") ADMIN_USER = settings.get("admin", {}).get("username", "admin")
ADMIN_PASS = settings.get("admin", {}).get("password", "") 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: def check_admin(request: Request) -> bool:
if not ADMIN_PASS: if not ADMIN_PASS:
return True return True
token = request.cookies.get("admin_token", "") token = request.cookies.get("admin_token", "")
if token:
if token == ADMIN_PASS: if token == ADMIN_PASS:
return True return True
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
@@ -615,16 +853,26 @@ def check_admin(request: Request) -> bool:
@app.get("/admin/login") @app.get("/admin/login")
async def admin_login_page(): 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") @app.post("/admin/login")
async def admin_login(request: Request): 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='<div class="login-err">Слишком много попыток. Подождите.</div>'),
status_code=429)
data = await request.form() data = await request.form()
if data.get("username") == ADMIN_USER and data.get("password") == ADMIN_PASS: if data.get("username") == ADMIN_USER and data.get("password") == ADMIN_PASS:
resp = RedirectResponse(url="/admin/users", status_code=303) 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 resp
return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg='<div class="login-err">Неверный пароль</div>'), status_code=401) return HTMLResponse(
content=render_template("admin_login.html", title=h(settings.get("general", {}).get("title", "ZernProxy")),
error_msg='<div class="login-err">Неверный пароль</div>'),
status_code=401)
@app.get("/sub/{subscription_id}") @app.get("/sub/{subscription_id}")
async def get_subscription(request: Request, subscription_id: str, format: str = Query("base64", pattern="^(json|base64|raw)$")): async def get_subscription(request: Request, subscription_id: str, format: str = Query("base64", pattern="^(json|base64|raw)$")):
@@ -643,9 +891,6 @@ async def get_subscription(request: Request, subscription_id: str, format: str =
servers_for_tier = get_servers_for_tier(tier) servers_for_tier = get_servers_for_tier(tier)
inbounds = deduplicate_inbounds(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) all_links = get_cached_links(subscription_id)
if all_links is None: if all_links is None:
all_links = [] all_links = []
@@ -660,9 +905,14 @@ async def get_subscription(request: Request, subscription_id: str, format: str =
servers_processed.add(srv_name) servers_processed.add(srv_name)
sub_path = srv["sub_path"].format(sub_id=subscription_id) sub_path = srv["sub_path"].format(sub_id=subscription_id)
url = f"{srv['subscription_url'].rstrip('/')}{sub_path}" 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, []) links = server_links.get(srv_name, [])
if not links:
continue
srv_inbounds = [ib for s, ib in inbounds if s["name"] == srv_name] srv_inbounds = [ib for s, ib in inbounds if s["name"] == srv_name]
try: try:
link_idx = srv_inbounds.index(inbound) link_idx = srv_inbounds.index(inbound)
@@ -683,9 +933,6 @@ async def get_subscription(request: Request, subscription_id: str, format: str =
set_cached_links(subscription_id, all_links) set_cached_links(subscription_id, all_links)
if not all_links:
raise HTTPException(404, "No links found")
if format == "json": if format == "json":
return {"links": all_links, "count": len(all_links), "tier": tier} return {"links": all_links, "count": len(all_links), "tier": tier}
@@ -755,7 +1002,6 @@ async def get_subscription(request: Request, subscription_id: str, format: str =
if support_url: if support_url:
headers["Sub-Expire-Button-Link"] = 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", "") 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"): if is_wdtt_client or user.get("wdtt_password"):
wdtt_servers = get_wdtt_servers() wdtt_servers = get_wdtt_servers()
@@ -778,7 +1024,6 @@ async def get_subscription(request: Request, subscription_id: str, format: str =
"server_id": user.get("wdtt_server_id", 0), "server_id": user.get("wdtt_server_id", 0),
}) })
# ZernHash: случайные хеши для клиента
if is_wdtt_client or "ZernHash" in request.headers.get("User-Agent", ""): if is_wdtt_client or "ZernHash" in request.headers.get("User-Agent", ""):
hashes = get_random_hashes(4) hashes = get_random_hashes(4)
if hashes: if hashes:
@@ -807,11 +1052,11 @@ async def get_web_page(subscription_id: str):
logo_base64 = get_logo_base64() 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>' 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 '' announcement_html = f'<div class="announcement">{h(announcement)}</div>' if announcement else ''
tier_color = "#4CAF50" if tier == "paid" else "#757575" tier_color = "#4CAF50" if tier == "paid" else "#757575"
tier_name = tier_config.get("name", "Free") tier_name = tier_config.get("name", "Free")
tier_badge = f'<span class="tier-badge" style="background: {tier_color}">{tier_name}</span>' tier_badge = f'<span class="tier-badge" style="background: {tier_color}">{h(tier_name)}</span>'
days_remaining = get_remaining_days(user) days_remaining = get_remaining_days(user)
days_info = f"<p>⏳ Осталось дней: {days_remaining}</p>" if tier == "paid" and days_remaining > 0 else "" days_info = f"<p>⏳ Осталось дней: {days_remaining}</p>" if tier == "paid" and days_remaining > 0 else ""
@@ -828,26 +1073,26 @@ async def get_web_page(subscription_id: str):
if traffic.get("by_server"): if traffic.get("by_server"):
for srv_name, data in traffic["by_server"].items(): for srv_name, data in traffic["by_server"].items():
if data["up"] or data["down"]: 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>' traffic_details += f'<div class="traffic-server"><span class="server-name">{h(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 '' 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="srv-tag">{get_flag_emoji(srv.get("country", ""))} {srv["name"].upper()}</span>' for srv in servers_for_tier) servers_html = "".join(f'<span class="srv-tag">{get_flag_emoji(srv.get("country", ""))} {h(srv["name"].upper())}</span>' for srv in servers_for_tier)
support_btn = "" support_btn = ""
if da_config.get("enabled"): if da_config.get("enabled"):
da_url = da_config.get("url", "#") da_url = da_config.get("url", "#")
support_btn = f''' support_btn = f'''
<a href="{da_url}" class="btn btn-support" target="_blank"> <a href="{h(da_url)}" class="btn btn-support" target="_blank">
Поддержать проект Поддержать проект
</a> </a>
''' '''
html = render_template("sub.html", 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, announcement_html=announcement_html, info_html=info_html,
servers_html=servers_html, qr_base64=qr_base64, 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) return HTMLResponse(content=html)
@app.post("/payment/webhook/donationalerts") @app.post("/payment/webhook/donationalerts")
@@ -940,7 +1185,7 @@ async def webhook_donationalerts(request: Request):
@app.get("/admin/users") @app.get("/admin/users")
async def admin_users(request: Request): async def admin_users(request: Request):
if not check_admin(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() conn = get_db()
try: try:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall() users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
@@ -951,38 +1196,38 @@ async def admin_users(request: Request):
title = settings.get("general", {}).get("title", "ZernProxy") title = settings.get("general", {}).get("title", "ZernProxy")
tier_options = "".join( tier_options = "".join(
f'<option value="{k}">{v.get("name", k.capitalize())}</option>' f'<option value="{h(k)}">{h(v.get("name", k.capitalize()))}</option>'
for k, v in settings.get("tiers", {}).items()) for k, v in settings.get("tiers", {}).items())
users_rows = "" users_rows = ""
for u in users: for u in users:
traffic = f'{u["traffic_limit_gb"]} GB' if u['traffic_limit_gb'] > 0 else '&#8734;' traffic = f'{u["traffic_limit_gb"]} GB' if u['traffic_limit_gb'] > 0 else '&#8734;'
active = '&#10003;' if u['is_active'] else '&#10007;' active = '&#10003;' if u['is_active'] else '&#10007;'
tier_display = u['tier'].upper() tier_display = h(u['tier'].upper())
rem_days = get_remaining_days(u) rem_days = get_remaining_days(u)
users_rows += f'''<tr> users_rows += f'''<tr>
<td>{u['id']}</td> <td>{u['id']}</td>
<td style="font-weight:600;color:var(--text)">{u['username']}</td> <td style="font-weight:600;color:var(--text)">{h(u['username'])}</td>
<td><code>{u['subscription_id']}</code></td> <td><code>{h(u['subscription_id'])}</code></td>
<td><span class="badge badge-{u['tier']}">{tier_display}</span></td> <td><span class="badge badge-{h(u['tier'])}">{tier_display}</span></td>
<td>{rem_days}</td> <td>{rem_days}</td>
<td>{u['total_paid_rubles']}&#8381;</td> <td>{u['total_paid_rubles']}&#8381;</td>
<td>{traffic}</td> <td>{traffic}</td>
<td>{active}</td> <td>{active}</td>
<td>{u['created_at'][:10]}</td> <td>{u['created_at'][:10]}</td>
<td> <td>
<button class="btn-sm" onclick="editUser({u['id']}, '{u['tier']}', {rem_days}, {u['traffic_limit_gb']}, {1 if u['is_active'] else 0})">&#9997;&#65039;</button> <button class="btn-sm" onclick="editUser({u['id']}, '{h(u['tier'])}', {rem_days}, {u['traffic_limit_gb']}, {1 if u['is_active'] else 0})">&#9997;&#65039;</button>
<button class="btn-sm danger" onclick="deleteUser({u['id']}, '{u['username']}')">&#128465;&#65039;</button> <button class="btn-sm danger" onclick="deleteUser({u['id']}, '{h(u['username'])}')">&#128465;&#65039;</button>
</td> </td>
</tr>''' </tr>'''
html = render_template("admin_users.html", 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) tier_options=tier_options)
return HTMLResponse(content=html) return HTMLResponse(content=html)
@app.get("/admin/dashboard") @app.get("/admin/dashboard")
async def admin_dashboard(request: Request): async def admin_dashboard(request: Request):
if not check_admin(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() conn = get_db()
try: try:
@@ -997,6 +1242,8 @@ async def admin_dashboard(request: Request):
online_count = 0 online_count = 0
for srv in servers: for srv in servers:
if not is_server_alive(srv.get("name", "")):
continue
for inbound in srv.get("inbounds", []): for inbound in srv.get("inbounds", []):
try: try:
api = Api(host=inbound["api_host"], username=inbound["api_user"], password=inbound["api_pass"], use_tls_verify=False) api = Api(host=inbound["api_host"], username=inbound["api_user"], password=inbound["api_pass"], use_tls_verify=False)
@@ -1008,10 +1255,10 @@ async def admin_dashboard(request: Request):
title = settings.get("general", {}).get("title", "ZernProxy") title = settings.get("general", {}).get("title", "ZernProxy")
servers_rows = "".join( servers_rows = "".join(
f'<div class="srv-row"><span class="srv-name">{get_flag_emoji(s.get("country",""))} {s["name"].upper()}</span><span class="srv-ibs">{len(s.get("inbounds",[]))} inbound</span><button class="btn-sm" onclick="syncServer(\'{s["name"]}\')">&#128260;</button></div>' f'<div class="srv-row"><span class="srv-name">{get_flag_emoji(s.get("country",""))} {h(s["name"].upper())}</span><span class="srv-ibs">{len(s.get("inbounds",[]))} inbound</span><button class="btn-sm" onclick="syncServer(\'{h(s["name"])}\')">&#128260;</button></div>'
for s in servers) for s in servers)
html = render_template("admin_dashboard.html", 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, paid_users=paid_users, test_users=test_users,
online_count=online_count, total_revenue=total_revenue, online_count=online_count, total_revenue=total_revenue,
server_count=len(servers), server_count=len(servers),
@@ -1070,7 +1317,7 @@ async def home_page():
for ib in srv_inbounds: for ib in srv_inbounds:
ib_name = ib.get("name", "unknown") ib_name = ib.get("name", "unknown")
ib_type = ib.get("network", ib.get("flow", "")) ib_type = ib.get("network", ib.get("flow", ""))
inbound_cards += f'<div class="ib-card"><span class="ib-dot"></span><span class="ib-name">{ib_name}</span><span class="ib-type">{ib_type}</span></div>\n' inbound_cards += f'<div class="ib-card"><span class="ib-dot"></span><span class="ib-name">{h(ib_name)}</span><span class="ib-type">{h(ib_type)}</span></div>\n'
ru_note = "" ru_note = ""
if s.get("country", "").lower() == "ru" and online: if s.get("country", "").lower() == "ru" and online:
@@ -1079,7 +1326,7 @@ async def home_page():
srv_cards += f''' srv_cards += f'''
<div class="s-card" style="--d: {delay}s; --hl: {hc}"> <div class="s-card" style="--d: {delay}s; --hl: {hc}">
<div class="s-head"> <div class="s-head">
<span class="flag">{get_flag_emoji(s.get("country",""))}</span> {srv_name} {badge} <span class="flag">{get_flag_emoji(s.get("country",""))}</span> {h(srv_name)} {badge}
<span class="h-dot" style="background:{hc}"></span> <span class="h-dot" style="background:{hc}"></span>
<span class="h-lbl" style="color:{hc}">{hl}</span> <span class="h-lbl" style="color:{hc}">{hl}</span>
</div> </div>
@@ -1088,7 +1335,7 @@ async def home_page():
{ru_note} {ru_note}
</div>''' </div>'''
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: async def fetch_servers_status() -> list:
@@ -1110,6 +1357,7 @@ async def fetch_servers_status() -> list:
else: else:
entry["checks"]["error"] = {"value": f"HTTP {resp.status_code}"} entry["checks"]["error"] = {"value": f"HTTP {resp.status_code}"}
except Exception as e: except Exception as e:
logger.warning(f"Status check failed for {srv.get('name','?')}: {e}")
entry["checks"]["error"] = {"value": str(e)[:50]} entry["checks"]["error"] = {"value": str(e)[:50]}
results.append(entry) results.append(entry)
return results return results
@@ -1122,7 +1370,6 @@ async def tg_webhook():
return JSONResponse({"status": "ok", "bot": False, "message": "Telegram bot not configured"}) 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: 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_host = inbound.get("api_host")
api_user = inbound.get("api_user") api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass") api_pass = inbound.get("api_pass")
@@ -1168,7 +1415,7 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in
existing = api.client.get_by_email(email) existing = api.client.get_by_email(email)
if existing: if existing:
logger.info(f"Deleting existing client: {existing.id}") logger.info(f"Deleting existing client: {existing.id}")
api.client.delete(existing.id, inbound_id) api.client.delete(inbound_id, existing.id)
except: except:
pass pass
@@ -1184,7 +1431,6 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in
return {"success": False, "error": str(e)[:150]} return {"success": False, "error": str(e)[:150]}
def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict: def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict:
"""Удаляет клиента с 3x-UI сервера"""
api_host = inbound.get("api_host") api_host = inbound.get("api_host")
api_user = inbound.get("api_user") api_user = inbound.get("api_user")
api_pass = inbound.get("api_pass") api_pass = inbound.get("api_pass")
@@ -1252,12 +1498,14 @@ def propagate_server_sync(server_name: str) -> dict:
target_srv = next((s for s in servers if s["name"] == server_name), None) target_srv = next((s for s in servers if s["name"] == server_name), None)
if not target_srv: if not target_srv:
return {"error": "Server not found"} return {"error": "Server not found"}
if not is_server_alive(server_name):
return {"error": "Target server is dead"}
conn = get_db() conn = get_db()
try: try:
users = conn.execute("SELECT username, subscription_id, traffic_limit_gb, is_active FROM users WHERE is_active = 1").fetchall() users = conn.execute("SELECT username, subscription_id, traffic_limit_gb, is_active FROM users WHERE is_active = 1").fetchall()
finally: finally:
conn.close() 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) threshold = max(1, len(other_servers) // 2 + 1)
target_ib_ids = fetch_server_sub_ids(target_srv) target_ib_ids = fetch_server_sub_ids(target_srv)
@@ -1337,6 +1585,8 @@ async def create_user(request: Request, data: dict):
results = [] results = []
for srv in servers: for srv in servers:
if not is_server_alive(srv.get("name", "")):
continue
srv_result = {"server": srv["name"], "inbounds": []} srv_result = {"server": srv["name"], "inbounds": []}
for inbound in srv.get("inbounds", []): for inbound in srv.get("inbounds", []):
result = create_3xui_client(username, sub_id, inbound, traffic_gb) result = create_3xui_client(username, sub_id, inbound, traffic_gb)
@@ -1375,7 +1625,6 @@ async def create_user(request: Request, data: dict):
async def update_user(request: Request, data: dict): async def update_user(request: Request, data: dict):
if not check_admin(request): if not check_admin(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401) return JSONResponse({"error": "Unauthorized"}, status_code=401)
logger.info(f"RAW data received: {data}")
user_id = int(data.get("id", 0)) user_id = int(data.get("id", 0))
tier = str(data.get("tier", "free")) tier = str(data.get("tier", "free"))
@@ -1395,7 +1644,6 @@ async def update_user(request: Request, data: dict):
conn.commit() conn.commit()
updated = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() updated = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
logger.info(f"After update DB: {dict(updated)}")
finally: finally:
conn.close() conn.close()
@@ -1424,8 +1672,10 @@ async def delete_user(request: Request, data: dict):
sub_id = user["subscription_id"] sub_id = user["subscription_id"]
for srv in servers: for srv in servers:
if not is_server_alive(srv.get("name", "")):
continue
for inbound in srv.get("inbounds", []): 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)) asyncio.create_task(remove_wdtt_password(user))
@@ -1436,43 +1686,7 @@ async def delete_user(request: Request, data: dict):
return JSONResponse({"status": "ok", "message": "User deleted from all servers"}) 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): def update_3xui_expiry(sub_id: str, days: int):
"""Обновляет expiry_time на всех 3x-UI серверах"""
import time
expiry_timestamp = int(time.time() + (days * 86400)) expiry_timestamp = int(time.time() + (days * 86400))
for srv in servers: for srv in servers:
@@ -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") logger.info(f"Updated expiry for {srv['name']}/{inbound['name']}: {days} days")
break break
except Exception as e: except Exception as e:
logger.error(f"Error updating expiry {srv['name']}/{inbound['name']}: {e}") logger.warning(f"Skipping unavailable server {srv['name']}/{inbound['name']} for expiry update: {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)
@app.get("/health") @app.get("/health")
async def health(): async def health():
@@ -1625,6 +1727,31 @@ async def reload_configs(request: Request):
clear_cache() clear_cache()
return {"status": "ok"} 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") @app.get("/admin/api/rotate-shortids")
async def manual_rotate(request: Request): async def manual_rotate(request: Request):
if not check_admin(request): if not check_admin(request):
@@ -1633,8 +1760,4 @@ async def manual_rotate(request: Request):
return {"status": "ok"} return {"status": "ok"}
if __name__ == "__main__": if __name__ == "__main__":
loop = asyncio.new_event_loop() uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
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")