import asyncio import json import os import hashlib import secrets import time import re import socket import uuid import psutil from datetime import datetime, timedelta from urllib.parse import quote from collections import deque, defaultdict from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Depends from fastapi.responses import Response, HTMLResponse, JSONResponse, RedirectResponse from fastapi.middleware.cors import CORSMiddleware import uvicorn import httpx import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("NyxRelay-Gateway") app = FastAPI(title="NyxRelay", docs_url=None, redoc_url=None) CONFIG = { "port": int(os.environ.get("PORT", 7860)), "secret": os.environ.get("SECRET_KEY", "nyxrelay-default-secret-key"), } PANEL_VERSION = os.environ.get("PANEL_VERSION", "v1.0.0") CORE_VERSION = os.environ.get("CORE_VERSION", "v26.4.25") TELEGRAM_HANDLE = os.environ.get("TELEGRAM_HANDLE", "@NyxRelay") SERVICE_RUNNING = True SERVICE_STARTED_AT = time.time() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) connections: dict = {} connection_sockets: dict = {} link_ip_map: dict = defaultdict(set) stats = {"total_bytes": 0, "total_requests": 0, "total_errors": 0, "start_time": time.time()} error_logs: deque = deque(maxlen=50) hourly_traffic: dict = defaultdict(int) http_client: httpx.AsyncClient | None = None # Track network baseline for speed calculation _net_baseline = {"bytes_sent": 0, "bytes_recv": 0, "ts": time.time()} LINKS: dict = {} LINKS_LOCK = asyncio.Lock() CUSTOM_ADDRESSES: list = ["amazonaws.com"] CUSTOM_ADDRESSES_LOCK = asyncio.Lock() CUSTOM_DOMAIN: str = "" CUSTOM_DOMAIN_LOCK = asyncio.Lock() SESSION_COOKIE = "nyxrelay_session" SESSION_TTL = 60 * 60 * 24 * 7 def hash_password(pw: str) -> str: return hashlib.sha256(f"{pw}{CONFIG['secret']}".encode()).hexdigest() AUTH = { "password_hash": hash_password(os.environ.get("ADMIN_PASSWORD", "admin")), "username": os.environ.get("ADMIN_USERNAME", "admin"), } SESSIONS: dict = {} SESSIONS_LOCK = asyncio.Lock() async def create_session() -> str: token = secrets.token_urlsafe(32) async with SESSIONS_LOCK: SESSIONS[token] = time.time() + SESSION_TTL return token async def is_valid_session(token: str | None) -> bool: if not token: return False async with SESSIONS_LOCK: exp = SESSIONS.get(token) if exp is None or exp < time.time(): SESSIONS.pop(token, None) return False return True async def destroy_session(token: str | None): if token: async with SESSIONS_LOCK: SESSIONS.pop(token, None) async def require_auth(request: Request): token = request.cookies.get(SESSION_COOKIE) if not await is_valid_session(token): raise HTTPException(status_code=401, detail="unauthorized") return token async def keep_alive(): while True: await asyncio.sleep(600) try: domain = get_domain() if domain and domain != "localhost": async with httpx.AsyncClient(timeout=10.0) as client: await client.get(f"https://{domain}/health") logger.info("Keep-alive ping sent") except Exception: pass @app.on_event("startup") async def startup(): global http_client, _net_baseline limits = httpx.Limits(max_connections=500, max_keepalive_connections=100) timeout = httpx.Timeout(30.0, connect=10.0) http_client = httpx.AsyncClient(limits=limits, timeout=timeout, follow_redirects=True) try: nc = psutil.net_io_counters() _net_baseline = {"bytes_sent": nc.bytes_sent, "bytes_recv": nc.bytes_recv, "ts": time.time()} except Exception: _net_baseline = {"bytes_sent": 0, "bytes_recv": 0, "ts": time.time()} logger.info(f"NyxRelay started on port {CONFIG['port']}") asyncio.create_task(keep_alive()) @app.on_event("shutdown") async def shutdown(): if http_client: await http_client.aclose() def get_domain() -> str: return os.environ.get("SPACE_HOST", "localhost").replace("https://", "").replace("http://", "") def generate_uuid(seed: str | None = None) -> str: # Real, standard RFC-4122 UUID if seed is None: return str(uuid.uuid4()) h = hashlib.sha256(f"{seed}{CONFIG['secret']}".encode()).hexdigest() return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}" def generate_vless_link(uuid: str, remark: str = "NyxRelay", address: str = None) -> str: domain = CUSTOM_DOMAIN if CUSTOM_DOMAIN else get_domain() addr = address if address else domain path = f"/ws/{uuid}" params = { "encryption": "none", "security": "tls", "type": "ws", "host": domain, "path": path, "sni": domain, "fp": "chrome", "alpn": "http/1.1", } query = "&".join(f"{k}={quote(str(v))}" for k, v in params.items()) return f"vless://{uuid}@{addr}:443?{query}#{quote(remark)}" def uptime_seconds() -> int: return int(time.time() - stats["start_time"]) def uptime() -> str: secs = uptime_seconds() h, m, s = secs // 3600, (secs % 3600) // 60, secs % 60 return f"{h:02d}:{m:02d}:{s:02d}" def os_uptime_str() -> str: try: secs = int(time.time() - psutil.boot_time()) d = secs // 86400 h = (secs % 86400) // 3600 m = (secs % 3600) // 60 if d > 0: return f"{d}d {h}h {m}m" elif h > 0: return f"{h}h {m}m" else: return f"{m}m" except Exception: return "N/A" def parse_size_to_bytes(value: float, unit: str) -> int: unit = unit.upper() if unit == "GB": return int(value * 1024 * 1024 * 1024) if unit == "MB": return int(value * 1024 * 1024) if unit == "KB": return int(value * 1024) return int(value) def compute_expiry(expiry_days) -> str: try: days = float(expiry_days or 0) except (TypeError, ValueError): days = 0 if days <= 0: return "" return (datetime.now() + timedelta(days=days)).isoformat() def is_expired(link) -> bool: exp = link.get("expiry") if isinstance(link, dict) else None if not exp: return False try: return datetime.now() >= datetime.fromisoformat(exp) except (TypeError, ValueError): return False def expiry_epoch(link) -> int: exp = link.get("expiry") if isinstance(link, dict) else None if not exp: return 0 try: return int(datetime.fromisoformat(exp).timestamp()) except (TypeError, ValueError): return 0 async def ensure_default_link(): async with LINKS_LOCK: if not LINKS: uid = generate_uuid() LINKS[uid] = {"label": "Default", "limit_bytes": 0, "used_bytes": 0, "max_connections": 0, "created_at": datetime.now().isoformat(), "active": True, "expiry": ""} def get_client_ip(websocket: WebSocket) -> str: forwarded = websocket.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() if websocket.client: return websocket.client.host return "unknown" def count_connections_for_link(uid: str) -> int: return len(link_ip_map.get(uid, set())) def remove_ip_from_link(uid: str, ip: str): if uid in link_ip_map: link_ip_map[uid].discard(ip) if not link_ip_map[uid]: link_ip_map.pop(uid, None) async def close_connections_for_link(uid: str): to_close = [cid for cid, info in connections.items() if info.get("uuid") == uid] for cid in to_close: ws = connection_sockets.get(cid) if ws: try: await ws.close(code=1000, reason="link deleted") except Exception: pass connections.pop(cid, None) connection_sockets.pop(cid, None) link_ip_map.pop(uid, None) def get_real_ips(): ipv4 = "" ipv6 = "" try: for iface, addrs in psutil.net_if_addrs().items(): for addr in addrs: if addr.family == socket.AF_INET and not addr.address.startswith("127."): ipv4 = addr.address elif addr.family == socket.AF_INET6 and not addr.address.startswith("::1") and not addr.address.startswith("fe80"): ipv6 = addr.address.split("%")[0] except Exception: pass return ipv4, ipv6 def get_net_speed(): global _net_baseline try: nc = psutil.net_io_counters() now = time.time() elapsed = now - _net_baseline["ts"] if elapsed < 0.1: elapsed = 1.0 up_bps = (nc.bytes_sent - _net_baseline["bytes_sent"]) / elapsed down_bps = (nc.bytes_recv - _net_baseline["bytes_recv"]) / elapsed _net_baseline = {"bytes_sent": nc.bytes_sent, "bytes_recv": nc.bytes_recv, "ts": now} return nc.bytes_sent, nc.bytes_recv, up_bps, down_bps except Exception: return 0, 0, 0, 0 def fmt_bytes_speed(bps: float) -> str: if bps >= 1_048_576: return f"{bps/1_048_576:.2f} MB" elif bps >= 1024: return f"{bps/1024:.2f} KB" else: return f"{bps:.0f} B" def fmt_bytes(b: int) -> str: if b >= 1_073_741_824: return f"{b/1_073_741_824:.2f} GB" elif b >= 1_048_576: return f"{b/1_048_576:.2f} MB" elif b >= 1024: return f"{b/1024:.1f} KB" return f"{b} B" def get_net_connections_count(): try: conns = psutil.net_connections() tcp = sum(1 for c in conns if c.type == socket.SOCK_STREAM) udp = sum(1 for c in conns if c.type == socket.SOCK_DGRAM) return tcp, udp except Exception: return 0, 0 @app.get("/") async def root(request: Request): token = request.cookies.get(SESSION_COOKIE) if await is_valid_session(token): return RedirectResponse(url="/dashboard") return RedirectResponse(url="/login") @app.get("/health") async def health(): return {"status": "ok", "connections": len(connections), "uptime": uptime()} @app.post("/api/login") async def api_login(request: Request): body = await request.json() password = str(body.get("password") or "") username = str(body.get("username") or "") # Accept if username matches (or blank) AND password matches if username and username != AUTH["username"]: raise HTTPException(status_code=401, detail="Invalid username or password") if hash_password(password) != AUTH["password_hash"]: raise HTTPException(status_code=401, detail="Invalid username or password") token = await create_session() resp = JSONResponse({"ok": True}) resp.set_cookie(key=SESSION_COOKIE, value=token, max_age=SESSION_TTL, httponly=True, samesite="lax", path="/") return resp @app.post("/api/logout") async def api_logout(request: Request): token = request.cookies.get(SESSION_COOKIE) await destroy_session(token) resp = JSONResponse({"ok": True}) resp.delete_cookie(SESSION_COOKIE, path="/") return resp @app.get("/api/me") async def api_me(request: Request): token = request.cookies.get(SESSION_COOKIE) return {"authenticated": await is_valid_session(token)} @app.post("/api/change-password") async def api_change_password(request: Request, _=Depends(require_auth)): body = await request.json() current = str(body.get("current_password") or "") new = str(body.get("new_password") or "") if hash_password(current) != AUTH["password_hash"]: raise HTTPException(status_code=400, detail="Current password is incorrect") if len(new) < 4: raise HTTPException(status_code=400, detail="Password must be at least 4 characters") AUTH["password_hash"] = hash_password(new) current_token = request.cookies.get(SESSION_COOKIE) async with SESSIONS_LOCK: SESSIONS.clear() if current_token: SESSIONS[current_token] = time.time() + SESSION_TTL return {"ok": True} @app.get("/stats") async def get_stats(_=Depends(require_auth)): return { "active_connections": len(connections), "total_traffic_mb": round(stats["total_bytes"] / (1024 * 1024), 2), "total_requests": stats["total_requests"], "total_errors": stats["total_errors"], "uptime": uptime(), "timestamp": datetime.now().isoformat(), "recent_errors": list(error_logs)[-10:], "links_count": len(LINKS), "domain": get_domain(), "cpu_percent": psutil.cpu_percent(interval=0.1), "memory_percent": psutil.virtual_memory().percent, "hourly_traffic": dict(hourly_traffic), } @app.get("/api/stats") async def get_api_stats(_=Depends(require_auth)): cpu = psutil.cpu_percent(interval=0.1) vm = psutil.virtual_memory() ram_pct = vm.percent ram_used_mb = vm.used / 1_048_576 ram_total_mb = vm.total / 1_048_576 load_avg = [0.0, 0.0, 0.0] try: load_avg = list(os.getloadavg()) except Exception: pass total_sent_bytes, total_recv_bytes, up_bps, down_bps = get_net_speed() ipv4, ipv6 = get_real_ips() tcp, udp = get_net_connections_count() try: proc = psutil.Process() threads = proc.num_threads() proc_ram = proc.memory_info().rss / 1_048_576 except Exception: threads = 0 proc_ram = ram_used_mb # Swap (real) try: sw = psutil.swap_memory() swap_pct = sw.percent swap_used = sw.used swap_total = sw.total except Exception: swap_pct, swap_used, swap_total = 0.0, 0, 0 # Storage (real, root filesystem) try: du = psutil.disk_usage("/") disk_pct = du.percent disk_used = du.used disk_total = du.total except Exception: disk_pct, disk_used, disk_total = 0.0, 0, 0 # CPU cores (real) try: cpu_cores = psutil.cpu_count(logical=True) or 1 except Exception: cpu_cores = 1 # Xray (tunnel core) uptime: measured from the last (re)start of the service if SERVICE_RUNNING: xray_uptime_s = int(time.time() - SERVICE_STARTED_AT) if xray_uptime_s < 3600: xray_uptime = f"{xray_uptime_s // 60}m" elif xray_uptime_s < 86400: xray_uptime = f"{xray_uptime_s // 3600}h {(xray_uptime_s % 3600)//60}m" else: xray_uptime = f"{xray_uptime_s // 86400}d {(xray_uptime_s % 86400)//3600}h" else: xray_uptime = "Stopped" return { "cpuUsage": round(cpu, 1), "ramUsage": round(ram_pct, 1), "ramUsed": f"{ram_used_mb:.1f} MB", "ramTotal": f"{ram_total_mb:.0f} MB", "uptime": os_uptime_str(), "xrayUptime": xray_uptime, "systemLoad": f"{load_avg[0]:.2f} | {load_avg[1]:.2f} | {load_avg[2]:.2f}", "threads": threads, "uploadSpeed": fmt_bytes_speed(up_bps) + "/s", "downloadSpeed": fmt_bytes_speed(down_bps) + "/s", "totalSent": fmt_bytes(total_sent_bytes), "totalReceived": fmt_bytes(total_recv_bytes), "ipv4": ipv4 or "N/A", "ipv6": ipv6 or "N/A", "tcpConnections": tcp, "udpConnections": udp, "activeConnections": len(connections), "totalTrafficMb": round(stats["total_bytes"] / 1_048_576, 2), "totalRequests": stats["total_requests"], "linksCount": len(LINKS), "domain": get_domain(), "hourlyTraffic": dict(hourly_traffic), "recentErrors": list(error_logs)[-5:], "cpuCores": cpu_cores, "swapUsage": round(swap_pct, 1), "swapUsed": fmt_bytes(swap_used), "swapTotal": fmt_bytes(swap_total), "storageUsage": round(disk_pct, 1), "storageUsed": fmt_bytes(disk_used), "storageTotal": fmt_bytes(disk_total), "appRam": f"{proc_ram:.2f} MB", "xrayRunning": SERVICE_RUNNING, "panelVersion": PANEL_VERSION, "coreVersion": CORE_VERSION, "telegram": TELEGRAM_HANDLE, } async def _stop_service_internal(): global SERVICE_RUNNING SERVICE_RUNNING = False for cid, ws in list(connection_sockets.items()): try: await ws.close(code=1012, reason="service stopped") except Exception: pass connections.clear() connection_sockets.clear() link_ip_map.clear() @app.get("/api/service") async def service_status(_=Depends(require_auth)): return {"running": SERVICE_RUNNING, "core_version": CORE_VERSION, "active_connections": len(connections)} @app.post("/api/service/stop") async def service_stop(_=Depends(require_auth)): await _stop_service_internal() logger.info("Core stopped via panel") return {"ok": True, "running": SERVICE_RUNNING} @app.post("/api/service/restart") async def service_restart(_=Depends(require_auth)): global SERVICE_RUNNING, SERVICE_STARTED_AT await _stop_service_internal() await asyncio.sleep(0.3) SERVICE_RUNNING = True SERVICE_STARTED_AT = time.time() logger.info("Core restarted via panel") return {"ok": True, "running": SERVICE_RUNNING} @app.get("/api/logs") async def get_logs(_=Depends(require_auth)): return { "running": SERVICE_RUNNING, "totals": { "bytes": stats["total_bytes"], "requests": stats["total_requests"], "errors": stats["total_errors"], }, "errors": list(error_logs)[-50:], "connections": [ {"id": cid, "uuid": info.get("uuid"), "ip": info.get("ip"), "connected_at": info.get("connected_at"), "bytes": info.get("bytes", 0)} for cid, info in connections.items() ], } @app.get("/api/config") async def get_runtime_config(_=Depends(require_auth)): async with LINKS_LOCK: inbounds = [{"uuid": uid, "remark": d["label"], "enabled": d["active"], "ws_path": f"/ws/{uid}"} for uid, d in LINKS.items()] async with CUSTOM_ADDRESSES_LOCK: addresses = list(CUSTOM_ADDRESSES) return { "panel": "NyxRelay", "panel_version": PANEL_VERSION, "core_version": CORE_VERSION, "running": SERVICE_RUNNING, "port": CONFIG["port"], "domain": CUSTOM_DOMAIN or get_domain(), "protocol": "vless", "network": "ws", "security": "tls", "clean_addresses": addresses, "inbounds": inbounds, } @app.get("/api/backup") async def download_backup(_=Depends(require_auth)): async with LINKS_LOCK: links_copy = {uid: dict(d) for uid, d in LINKS.items()} async with CUSTOM_ADDRESSES_LOCK: addresses = list(CUSTOM_ADDRESSES) backup = { "panel": "NyxRelay", "panel_version": PANEL_VERSION, "core_version": CORE_VERSION, "exported_at": datetime.now().isoformat(), "domain": CUSTOM_DOMAIN, "addresses": addresses, "username": AUTH["username"], "password_hash": AUTH["password_hash"], "links": links_copy, } content = json.dumps(backup, indent=2) fname = f"nyxrelay-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json" return Response(content=content, media_type="application/json", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.post("/api/restore") async def restore_backup(request: Request, _=Depends(require_auth)): global CUSTOM_DOMAIN body = await request.json() links = body.get("links") if not isinstance(links, dict): raise HTTPException(status_code=400, detail="Invalid backup file") async with LINKS_LOCK: LINKS.clear() for uid, d in links.items(): if not isinstance(d, dict): continue LINKS[uid] = { "label": str(d.get("label", "Restored"))[:60], "limit_bytes": int(d.get("limit_bytes", 0) or 0), "used_bytes": int(d.get("used_bytes", 0) or 0), "max_connections": int(d.get("max_connections", 0) or 0), "created_at": d.get("created_at", datetime.now().isoformat()), "active": bool(d.get("active", True)), "expiry": d.get("expiry", ""), } if isinstance(body.get("addresses"), list): async with CUSTOM_ADDRESSES_LOCK: CUSTOM_ADDRESSES.clear() for a in body["addresses"]: if isinstance(a, str) and a: CUSTOM_ADDRESSES.append(a) if isinstance(body.get("domain"), str): async with CUSTOM_DOMAIN_LOCK: CUSTOM_DOMAIN = body["domain"] return {"ok": True, "restored": len(LINKS)} @app.post("/api/links") async def create_link(request: Request, _=Depends(require_auth)): body = await request.json() label = (body.get("label") or "New Link").strip()[:60] if not re.match(r'^[a-zA-Z0-9\-_. ]+$', label): raise HTTPException(status_code=400, detail="Inbound name must contain only English letters, numbers, and characters: - _ . space") if not label: raise HTTPException(status_code=400, detail="Inbound name is required") async with LINKS_LOCK: if any(d["label"].lower() == label.lower() for d in LINKS.values()): raise HTTPException(status_code=400, detail="An inbound with this name already exists") limit_value = float(body.get("limit_value") or 0) limit_unit = body.get("limit_unit") or "GB" limit_bytes = 0 if limit_value <= 0 else parse_size_to_bytes(limit_value, limit_unit) max_conn = int(body.get("max_connections") or 0) if max_conn < 0: max_conn = 0 expiry = compute_expiry(body.get("expiry_days")) uid = generate_uuid() # real UUID is the connection credential async with LINKS_LOCK: LINKS[uid] = {"label": label, "limit_bytes": limit_bytes, "used_bytes": 0, "max_connections": max_conn, "created_at": datetime.now().isoformat(), "active": True, "expiry": expiry} return {"uuid": uid, "label": label, "limit_bytes": limit_bytes, "used_bytes": 0, "max_connections": max_conn, "active": True, "expiry": expiry, "created_at": LINKS[uid]["created_at"], "vless_link": generate_vless_link(uid, remark=f"NyxRelay-{label}")} @app.get("/api/links") async def list_links(_=Depends(require_auth)): result = [] async with LINKS_LOCK: for uid, data in LINKS.items(): result.append({"uuid": uid, "label": data["label"], "limit_bytes": data["limit_bytes"], "used_bytes": data["used_bytes"], "max_connections": data.get("max_connections", 0), "active": data["active"], "expiry": data.get("expiry", ""), "expired": is_expired(data), "created_at": data["created_at"], "current_connections": count_connections_for_link(uid), "vless_link": generate_vless_link(uid, remark=f"NyxRelay-{data['label']}")}) result.sort(key=lambda x: x["created_at"], reverse=True) return {"links": result} # Also expose as /api/inbounds for new UI compatibility @app.get("/api/inbounds") async def list_inbounds(_=Depends(require_auth)): result = [] async with LINKS_LOCK: for uid, data in LINKS.items(): result.append({ "id": uid, "uuid": uid, "remark": data["label"], "label": data["label"], "protocol": "vless", "enabled": data["active"], "active": data["active"], "limit_bytes": data["limit_bytes"], "used_bytes": data["used_bytes"], "total_flow": data["limit_bytes"] / 1_073_741_824 if data["limit_bytes"] > 0 else 0, "max_connections": data.get("max_connections", 0), "expiry": data.get("expiry", ""), "expired": is_expired(data), "created_at": data["created_at"], "current_connections": count_connections_for_link(uid), "vless_link": generate_vless_link(uid, remark=f"NyxRelay-{data['label']}"), "clients": [{"id": uid, "email": data["label"]}], }) result.sort(key=lambda x: x["created_at"], reverse=True) return {"items": result, "total": len(result)} @app.patch("/api/inbounds/{uid}") async def patch_inbound(uid: str, request: Request, _=Depends(require_auth)): body = await request.json() async with LINKS_LOCK: if uid not in LINKS: raise HTTPException(status_code=404, detail="inbound not found") if "enabled" in body: LINKS[uid]["active"] = bool(body["enabled"]) if "active" in body: LINKS[uid]["active"] = bool(body["active"]) if "limit_value" in body: lv = float(body.get("limit_value") or 0) lu = body.get("limit_unit") or "GB" LINKS[uid]["limit_bytes"] = 0 if lv <= 0 else parse_size_to_bytes(lv, lu) if "reset_usage" in body and body["reset_usage"]: LINKS[uid]["used_bytes"] = 0 if "expiry_days" in body: LINKS[uid]["expiry"] = compute_expiry(body.get("expiry_days")) if "label" in body: LINKS[uid]["label"] = str(body["label"])[:60] if "remark" in body: LINKS[uid]["label"] = str(body["remark"])[:60] if "max_connections" in body: mc = int(body["max_connections"] or 0) LINKS[uid]["max_connections"] = mc if mc >= 0 else 0 return {"ok": True} @app.delete("/api/inbounds/{uid}") async def delete_inbound(uid: str, _=Depends(require_auth)): async with LINKS_LOCK: LINKS.pop(uid, None) await close_connections_for_link(uid) return {"ok": True} @app.patch("/api/links/{uid}") async def toggle_link(uid: str, request: Request, _=Depends(require_auth)): body = await request.json() async with LINKS_LOCK: if uid not in LINKS: raise HTTPException(status_code=404, detail="link not found") if "active" in body: LINKS[uid]["active"] = bool(body["active"]) if "limit_value" in body: limit_value = float(body.get("limit_value") or 0) limit_unit = body.get("limit_unit") or "GB" LINKS[uid]["limit_bytes"] = 0 if limit_value <= 0 else parse_size_to_bytes(limit_value, limit_unit) if "reset_usage" in body and body["reset_usage"]: LINKS[uid]["used_bytes"] = 0 if "expiry_days" in body: LINKS[uid]["expiry"] = compute_expiry(body.get("expiry_days")) if "label" in body: LINKS[uid]["label"] = str(body["label"])[:60] if "max_connections" in body: mc = int(body["max_connections"] or 0) LINKS[uid]["max_connections"] = mc if mc >= 0 else 0 return {"ok": True} @app.delete("/api/links/{uid}") async def delete_link(uid: str, _=Depends(require_auth)): async with LINKS_LOCK: LINKS.pop(uid, None) await close_connections_for_link(uid) return {"ok": True} @app.get("/api/domain") async def get_custom_domain(_=Depends(require_auth)): async with CUSTOM_DOMAIN_LOCK: return {"domain": CUSTOM_DOMAIN} @app.post("/api/domain") async def set_custom_domain(request: Request, _=Depends(require_auth)): body = await request.json() domain = (body.get("domain") or "").strip().lower() if domain: domain = domain.replace("https://", "").replace("http://", "").rstrip("/") if not re.match(r'^[a-z0-9\-_.]+$', domain): raise HTTPException(status_code=400, detail="Invalid domain format") async with CUSTOM_DOMAIN_LOCK: global CUSTOM_DOMAIN CUSTOM_DOMAIN = domain return {"ok": True, "domain": CUSTOM_DOMAIN} @app.get("/api/addresses") async def list_addresses(_=Depends(require_auth)): async with CUSTOM_ADDRESSES_LOCK: return {"addresses": list(CUSTOM_ADDRESSES)} @app.post("/api/addresses") async def add_address(request: Request, _=Depends(require_auth)): body = await request.json() address = (body.get("address") or "").strip() if not address: raise HTTPException(status_code=400, detail="Address is required") if not re.match(r'^[a-zA-Z0-9\-_. ]+$', address): raise HTTPException(status_code=400, detail="Address must contain only English letters, numbers, and characters: - _ .") async with CUSTOM_ADDRESSES_LOCK: if address in CUSTOM_ADDRESSES: raise HTTPException(status_code=400, detail="Address already exists") CUSTOM_ADDRESSES.append(address) return {"ok": True, "addresses": list(CUSTOM_ADDRESSES)} @app.delete("/api/addresses/{index}") async def delete_address(index: int, _=Depends(require_auth)): async with CUSTOM_ADDRESSES_LOCK: if 0 <= index < len(CUSTOM_ADDRESSES): CUSTOM_ADDRESSES.pop(index) else: raise HTTPException(status_code=404, detail="Address not found") return {"ok": True, "addresses": list(CUSTOM_ADDRESSES)} @app.get("/api/links/{uid}/sub") async def get_subscription(uid: str, _=Depends(require_auth)): async with LINKS_LOCK: link = LINKS.get(uid) if link is None: raise HTTPException(status_code=404, detail="link not found") vless_link = generate_vless_link(uid, remark=f"NyxRelay-{link['label']}") used = link["used_bytes"] limit = link["limit_bytes"] used_mb = round(used / (1024 * 1024), 2) limit_mb = round(limit / (1024 * 1024), 2) if limit > 0 else 0 pct = round((used / limit) * 100, 1) if limit > 0 else 0 remaining_mb = round((limit - used) / (1024 * 1024), 2) if limit > 0 else 0 import base64 sub_content = f"""# NyxRelay Subscription # Label: {link['label']} # Used: {used_mb} MB / {limit_mb if limit > 0 else 'Unlimited'} MB # Remaining: {remaining_mb if limit > 0 else 'Unlimited'} MB # Usage: {pct}% # Status: {'Active' if link['active'] else 'Disabled'} # Expiry: {link.get('expiry', '')[:10] if link.get('expiry') else 'Unlimited'} {vless_link}""" encoded = base64.b64encode(sub_content.encode()).decode() return { "subscription_url": f"{get_domain()}/api/links/{uid}/sub", "config": vless_link, "label": link["label"], "used_bytes": used, "limit_bytes": limit, "used_mb": used_mb, "limit_mb": limit_mb, "remaining_mb": remaining_mb, "usage_percent": pct, "active": link["active"], "sub_base64": encoded, "sub_text": sub_content, } @app.get("/sub/{uid}") async def subscription_endpoint(uid: str): import base64 async with LINKS_LOCK: link = LINKS.get(uid) if link is None: raise HTTPException(status_code=404, detail="link not found") if not link["active"]: raise HTTPException(status_code=403, detail="link disabled") if is_expired(link): raise HTTPException(status_code=403, detail="link expired") async with CUSTOM_ADDRESSES_LOCK: addresses = list(CUSTOM_ADDRESSES) sub_links = [] server_link = generate_vless_link(uid, remark=f"NyxRelay-{link['label']}-Server") sub_links.append(server_link) for i, addr in enumerate(addresses): remark = f"NyxRelay-{link['label']}-IP{i+1}" vless_link = generate_vless_link(uid, remark=remark, address=addr) sub_links.append(vless_link) sub_content = "\n".join(sub_links) encoded = base64.b64encode(sub_content.encode()).decode() headers = { "Content-Type": "text/plain; charset=utf-8", "Content-Disposition": "attachment; filename=\"sub.txt\"", "profile-update-interval": "6", "subscription-userinfo": f"upload={link['used_bytes']}; download=0; total={link['limit_bytes']}; expire={expiry_epoch(link)}" } return Response(content=encoded, headers=headers) RELAY_BUF = 64 * 1024 async def parse_vless_header(first_chunk: bytes): if len(first_chunk) < 24: raise ValueError("chunk too small") pos = 0 pos += 1; pos += 16 addon_len = first_chunk[pos]; pos += 1; pos += addon_len command = first_chunk[pos]; pos += 1 port = int.from_bytes(first_chunk[pos:pos + 2], "big"); pos += 2 addr_type = first_chunk[pos]; pos += 1 if addr_type == 1: addr_bytes = first_chunk[pos:pos + 4]; pos += 4 address = ".".join(str(b) for b in addr_bytes) elif addr_type == 2: domain_len = first_chunk[pos]; pos += 1 address = first_chunk[pos:pos + domain_len].decode("utf-8", errors="ignore"); pos += domain_len elif addr_type == 3: addr_bytes = first_chunk[pos:pos + 16]; pos += 16 address = ":".join(f"{addr_bytes[i]:02x}{addr_bytes[i+1]:02x}" for i in range(0, 16, 2)) else: raise ValueError(f"unknown address type: {addr_type}") return command, address, port, first_chunk[pos:] async def check_quota(uid: str, extra_bytes: int) -> bool: async with LINKS_LOCK: link = LINKS.get(uid) if link is None: return False if not link["active"]: return False if is_expired(link): return False if link["limit_bytes"] == 0: return True return (link["used_bytes"] + extra_bytes) <= link["limit_bytes"] async def add_usage(uid: str, n: int): async with LINKS_LOCK: if uid in LINKS: LINKS[uid]["used_bytes"] += n async def ws_to_tcp(websocket: WebSocket, writer: asyncio.StreamWriter, conn_id: str, link_uid: str): try: while True: msg = await websocket.receive() if msg["type"] == "websocket.disconnect": break data = msg.get("bytes") or (msg.get("text") or "").encode() if not data: continue size = len(data) if not await check_quota(link_uid, size): await websocket.close(code=1008, reason="quota exceeded"); break stats["total_bytes"] += size; stats["total_requests"] += 1 connections[conn_id]["bytes"] += size hourly_traffic[datetime.now().strftime("%H:00")] += size await add_usage(link_uid, size) writer.write(data); await writer.drain() except WebSocketDisconnect: pass finally: try: writer.write_eof() except: pass async def tcp_to_ws(websocket: WebSocket, reader: asyncio.StreamReader, conn_id: str, link_uid: str): first = True try: while True: data = await reader.read(RELAY_BUF) if not data: break size = len(data) if not await check_quota(link_uid, size): await websocket.close(code=1008, reason="quota exceeded"); break stats["total_bytes"] += size connections[conn_id]["bytes"] += size hourly_traffic[datetime.now().strftime("%H:00")] += size await add_usage(link_uid, size) await websocket.send_bytes((b"\x00\x00" + data) if first else data) first = False except: pass @app.websocket("/ws/{uuid}") async def websocket_tunnel(websocket: WebSocket, uuid: str): await ensure_default_link() await websocket.accept() writer = None conn_id = None client_ip = get_client_ip(websocket) try: if not SERVICE_RUNNING: await websocket.close(code=1012, reason="service stopped"); return async with LINKS_LOCK: link_data = LINKS.get(uuid) if link_data is None or not link_data["active"]: await websocket.close(code=1008, reason="link not found or disabled"); return if is_expired(link_data): await websocket.close(code=1008, reason="link expired"); return max_conn = link_data.get("max_connections", 0) if max_conn > 0: already_connected = client_ip in link_ip_map.get(uuid, set()) if not already_connected: current = count_connections_for_link(uuid) if current >= max_conn: await websocket.close(code=1008, reason="connection limit reached"); return first_msg = await asyncio.wait_for(websocket.receive(), timeout=15.0) if first_msg["type"] == "websocket.disconnect": return first_chunk = first_msg.get("bytes") or (first_msg.get("text") or "").encode() if not first_chunk: return command, address, port, initial_payload = await parse_vless_header(first_chunk) conn_id = secrets.token_urlsafe(8) connections[conn_id] = {"uuid": uuid, "ip": client_ip, "connected_at": datetime.now().isoformat(), "bytes": 0} connection_sockets[conn_id] = websocket link_ip_map[uuid].add(client_ip) size = len(first_chunk) stats["total_bytes"] += size; stats["total_requests"] += 1 connections[conn_id]["bytes"] += size hourly_traffic[datetime.now().strftime("%H:00")] += size await add_usage(uuid, size) reader, writer = await asyncio.wait_for(asyncio.open_connection(address, port), timeout=10.0) if initial_payload: p_size = len(initial_payload) stats["total_bytes"] += p_size connections[conn_id]["bytes"] += p_size hourly_traffic[datetime.now().strftime("%H:00")] += p_size await add_usage(uuid, p_size) writer.write(initial_payload); await writer.drain() task_up = asyncio.create_task(ws_to_tcp(websocket, writer, conn_id, uuid)) task_down = asyncio.create_task(tcp_to_ws(websocket, reader, conn_id, uuid)) done, pending = await asyncio.wait({task_up, task_down}, return_when=asyncio.FIRST_COMPLETED) for t in pending: t.cancel() except WebSocketDisconnect: pass except Exception as exc: stats["total_errors"] += 1 error_logs.append({"error": str(exc), "time": datetime.now().isoformat()}) finally: if writer: try: writer.close() except: pass if conn_id: info = connections.pop(conn_id, None) connection_sockets.pop(conn_id, None) if info: uid = info.get("uuid") ip = info.get("ip") if uid and ip: has_other = any(c.get("uuid") == uid and c.get("ip") == ip for c in connections.values()) if not has_other: remove_ip_from_link(uid, ip) # ─── HTML Pages ──────────────────────────────────────────────────────────────── LOGIN_HTML = r""" NyxRelay - Welcome

NyxRelay

""" DASHBOARD_HTML = r""" NyxRelay - Dashboard
NyxRelay
0%
CPU: --
0%
RAM:
0%
Swap:
0%
Storage:
Xray
Running
{CORE}
Manage
NyxRelay
{PANEL} @NyxRelay Documentation
Uptime
Xray: -- OS: --
System Load
--
Usage
RAM: -- Threads: --
Overall Speed
Upload
-- B/s
Download
-- B/s
Total Data
Sent
--
Received
--
IP Addresses
IPv4
--
IPv6
--
Connection Stats
TCP
--
UDP
--
Sent / Received
-- / --
Proxy Traffic
-- MB
Total Inbounds
0
Active Clients
0
# Remark Type Traffic IPs Status Actions
Loading...
Clean IP / Addresses
IPs and domains for subscription configs. Default: www.speedtest.net
Custom Domain
Render Domain --
Custom Domain None set
Set a custom domain to use in VLESS configs instead of the default Render/HuggingFace domain. Point your domain via CNAME or A record to this service.
Change Password
""" @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): token = request.cookies.get(SESSION_COOKIE) if await is_valid_session(token): return RedirectResponse(url="/dashboard") return HTMLResponse(content=LOGIN_HTML) @app.get("/dashboard", response_class=HTMLResponse) async def dashboard_page(request: Request): token = request.cookies.get(SESSION_COOKIE) if not await is_valid_session(token): return RedirectResponse(url="/login") return HTMLResponse(content=DASHBOARD_HTML) # Legacy route aliases @app.get("/inbounds", response_class=HTMLResponse) async def inbounds_page(request: Request): token = request.cookies.get(SESSION_COOKIE) if not await is_valid_session(token): return RedirectResponse(url="/login") return RedirectResponse(url="/dashboard") @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): token = request.cookies.get(SESSION_COOKIE) if not await is_valid_session(token): return RedirectResponse(url="/login") return RedirectResponse(url="/dashboard") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=CONFIG["port"])