Download app.py from ManiAz09/xray2: direct link, hf CLI and curl.
- Browser
- Download file 116 kB
-
https://huggingface.co/spaces/ManiAz09/xray2/resolve/main/app.py
- Command line
-
hf download hf://spaces/ManiAz09/xray2/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ManiAz09/xray2/resolve/main/app.py
116 kB
| 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"), | |
| } | |
| # ββ Hardcoded startup client ββββββββββββββββββββββββββββββββββ | |
| HARDCODED_UUID = "8b46ed0f-1ed6-448f-9285-ebaf271c2fe0" | |
| HARDCODED_LABEL = "NarenjakVPNBot" | |
| HARDCODED_DOMAIN = "maniaz09-xray.hf.space" | |
| 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 | |
| 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) | |
| # Create the hardcoded client/link on startup so VLESS matches the target | |
| await ensure_default_link() | |
| 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']} β client {HARDCODED_UUID} ({HARDCODED_LABEL})") | |
| asyncio.create_task(keep_alive()) | |
| 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 get_domain_faiiled() -> str: | |
| import urllib.request | |
| try: | |
| # Attempt to get the public IPv4 address first (best for VPN/Proxy links) | |
| public_ip = urllib.request.urlopen('https://api.ipify.org', timeout=3).read().decode('utf8') | |
| return public_ip | |
| except Exception: | |
| # Fallback to the local interface IPv4 using your existing function | |
| ipv4, _ = get_real_ips() | |
| return ipv4 if ipv4 else "127.0.0.1" | |
| 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_old(): | |
| 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": ""} | |
| async def ensure_default_link(): | |
| async with LINKS_LOCK: | |
| if not LINKS: | |
| LINKS[HARDCODED_UUID] = { | |
| "label": HARDCODED_LABEL, | |
| "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 | |
| 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") | |
| async def health(): | |
| return {"status": "ok", "connections": len(connections), "uptime": uptime()} | |
| 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 | |
| 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 | |
| async def api_me(request: Request): | |
| token = request.cookies.get(SESSION_COOKIE) | |
| return {"authenticated": await is_valid_session(token)} | |
| 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} | |
| 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), | |
| } | |
| 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() | |
| async def service_status(_=Depends(require_auth)): | |
| return {"running": SERVICE_RUNNING, "core_version": CORE_VERSION, | |
| "active_connections": len(connections)} | |
| async def service_stop(_=Depends(require_auth)): | |
| await _stop_service_internal() | |
| logger.info("Core stopped via panel") | |
| return {"ok": True, "running": SERVICE_RUNNING} | |
| 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} | |
| 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() | |
| ], | |
| } | |
| 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, | |
| } | |
| 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}"'}) | |
| 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)} | |
| 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}")} | |
| 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 | |
| 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)} | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| async def get_custom_domain(_=Depends(require_auth)): | |
| async with CUSTOM_DOMAIN_LOCK: | |
| return {"domain": CUSTOM_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} | |
| async def list_addresses(_=Depends(require_auth)): | |
| async with CUSTOM_ADDRESSES_LOCK: | |
| return {"addresses": list(CUSTOM_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)} | |
| 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)} | |
| 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, | |
| } | |
| 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 | |
| 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"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>NyxRelay - Welcome</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1b2a;color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem} | |
| #login{background:#151f31;border-radius:2rem;padding:3rem 2rem 2.5rem;width:100%;max-width:380px;position:relative;animation:charge .5s ease both} | |
| @keyframes charge{0%{transform:translateY(2rem);opacity:0}100%{transform:translateY(0);opacity:1}} | |
| .setting-section{position:absolute;top:16px;right:16px} | |
| .ant-btn-circle{border-radius:50%;width:38px;height:38px;padding:0;border:none;background:#1e8a7a;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center} | |
| .ant-btn-circle:hover{background:#00a896} | |
| .title{font-size:2.2rem;font-weight:700;text-align:center;margin-bottom:2rem;color:#b0bec5;letter-spacing:1px} | |
| .words-wrapper{display:inline-block;position:relative;text-align:center;width:100%} | |
| .words-wrapper b{display:inline-block;position:absolute;left:0;top:0;width:100%;opacity:0} | |
| .words-wrapper b.is-visible{position:relative;opacity:1;animation:zoom-in .8s cubic-bezier(.215,.61,.355,1) forwards} | |
| .words-wrapper b.is-hidden{animation:zoom-out .4s cubic-bezier(.215,.61,.355,1) forwards} | |
| @keyframes zoom-in{0%{opacity:0;transform:translateZ(100px)}100%{opacity:1;transform:translateZ(0)}} | |
| @keyframes zoom-out{0%{opacity:1;transform:translateZ(0)}100%{opacity:0;transform:translateZ(-100px)}} | |
| .headline{display:flex;justify-content:center;align-items:center} | |
| .headline.zoom .words-wrapper{perspective:300px} | |
| .fields{display:flex;flex-direction:column;gap:14px;margin-bottom:1.5rem} | |
| .ant-input-affix-wrapper{display:flex;align-items:center;background:#1e2d42;border:1.5px solid #1e2d42;border-radius:30px;padding:0 16px;height:52px;transition:border-color .3s} | |
| .ant-input-affix-wrapper:focus-within{border-color:#008771;box-shadow:0 0 0 2px rgba(0,135,113,.15)} | |
| .ant-input-prefix{display:flex;align-items:center;margin-right:10px;color:#4a6080} | |
| .ant-input{flex:1;background:transparent;border:none;outline:none;color:#fff;font-size:14px;height:100%;padding:0} | |
| .ant-input::placeholder{color:#4a6080} | |
| .ant-input-suffix{display:flex;align-items:center;color:#4a6080;cursor:pointer;padding-left:8px} | |
| .ant-input-suffix:hover{color:#fff} | |
| .wave-btn-bg{border-radius:30px;overflow:hidden;position:relative} | |
| .wave-btn-bg-cl{background:linear-gradient(135deg,#007a68,#008771,#005565);background-size:200% 200%;transition:.3s} | |
| .wave-btn-bg-cl:hover{background-position:right center} | |
| .ant-btn-primary-login{font-size:15px;font-weight:600;color:#fff;background:transparent;border:none;height:50px;width:100%;cursor:pointer;letter-spacing:.5px} | |
| .err-msg{color:#ef4444;text-align:center;font-size:13px;padding:8px;background:rgba(239,68,68,0.1);border-radius:8px;display:none;margin-top:8px} | |
| .err-msg.show{display:block} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="login"> | |
| <div class="setting-section"> | |
| <button class="ant-btn-circle" onclick="toggleTheme()"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg> | |
| </button> | |
| </div> | |
| <h2 class="title headline zoom"><span class="words-wrapper"><b class="is-visible">NyxRelay</b></span></h2> | |
| <form id="login-form"> | |
| <div class="fields"> | |
| <span class="ant-input-affix-wrapper"> | |
| <span class="ant-input-prefix"> | |
| <svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M858.5 763.6a374 374 0 0 0-80.6-119.5 375.63 375.63 0 0 0-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 0 0-80.6 119.5A371.7 371.7 0 0 0 136 901.8a8 8 0 0 0 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c.1 4.4 3.6 7.8 8 7.8h60a8 8 0 0 0 8-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z"/></svg> | |
| </span> | |
| <input placeholder="Username" type="text" name="username" autocomplete="username" autofocus class="ant-input" id="username" value="admin"> | |
| </span> | |
| <span class="ant-input-affix-wrapper"> | |
| <span class="ant-input-prefix"> | |
| <svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor"><path d="M832 464h-68V240c0-70.7-57.3-128-128-128H388c-70.7 0-128 57.3-128 128v224h-68c-17.7 0-32 14.3-32 32v384c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zM332 240c0-30.9 25.1-56 56-56h248c30.9 0 56 25.1 56 56v224H332V240zm460 600H232V536h560v304zM484 701v53c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-53a48.01 48.01 0 1 0-56 0z"/></svg> | |
| </span> | |
| <input placeholder="Password" type="password" name="password" autocomplete="current-password" class="ant-input" id="password"> | |
| <span class="ant-input-suffix" onclick="togglePassword()"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg> | |
| </span> | |
| </span> | |
| </div> | |
| <div class="err-msg" id="err-msg"></div> | |
| <div class="wave-btn-bg wave-btn-bg-cl"> | |
| <button type="submit" class="ant-btn-primary-login">Log In</button> | |
| </div> | |
| </form> | |
| </div> | |
| <script> | |
| let darkTheme = true; | |
| function toggleTheme() { | |
| darkTheme = !darkTheme; | |
| document.body.style.background = darkTheme ? '#0d1b2a' : '#e8f5f2'; | |
| document.getElementById('login').style.background = darkTheme ? '#151f31' : '#fff'; | |
| } | |
| function togglePassword() { | |
| const input = document.getElementById('password'); | |
| input.type = input.type === 'password' ? 'text' : 'password'; | |
| } | |
| document.getElementById('login-form').addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const err = document.getElementById('err-msg'); | |
| err.classList.remove('show'); | |
| const username = document.getElementById('username').value; | |
| const password = document.getElementById('password').value; | |
| try { | |
| const res = await fetch('/api/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'include', | |
| body: JSON.stringify({ username, password }) | |
| }); | |
| if (!res.ok) { | |
| const d = await res.json().catch(() => ({})); | |
| err.textContent = d.detail || 'Invalid username or password'; | |
| err.classList.add('show'); | |
| return; | |
| } | |
| window.location.href = '/dashboard'; | |
| } catch(e) { | |
| err.textContent = 'Connection error'; | |
| err.classList.add('show'); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html>""" | |
| DASHBOARD_HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>NyxRelay - Dashboard</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a1222;color:rgba(255,255,255,0.75);min-height:100vh} | |
| ::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:3px} | |
| .ant-layout{display:flex;height:100vh} | |
| .ant-layout-has-sider{flex-direction:row} | |
| .ant-layout-sider{background:#111929;height:100%;display:flex;flex-direction:column;border-right:1px solid #1a1a1a;position:fixed;left:0;top:0;bottom:0;width:200px;z-index:100;transition:transform .3s} | |
| .ant-layout-sider-children{flex:1;display:flex;flex-direction:column;padding:8px;overflow-y:auto} | |
| .brand-title{padding:14px 12px 10px;display:flex;align-items:center;gap:8px;border-bottom:1px solid #1a1a1a;margin-bottom:6px} | |
| .brand-title svg{flex-shrink:0} | |
| .brand-title span{font-size:15px;font-weight:700;color:#fff;letter-spacing:-0.02em} | |
| .brand-title .version{font-size:10px;color:#555;font-weight:400;margin-left:2px} | |
| .ant-menu{list-style:none;padding:0;margin:0;background:transparent} | |
| .ant-menu-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;color:#888;font-size:13px;cursor:pointer;transition:all .15s;border:none;background:none;width:100%;text-align:left;margin:1px 0} | |
| .ant-menu-item:hover{background:rgba(0,135,113,0.08);color:#fff} | |
| .ant-menu-item-selected{background:rgba(0,135,113,0.12);color:#008771;font-weight:600} | |
| .nav-badge{margin-left:auto;background:#1a1a1a;color:#555;font-size:10px;padding:2px 7px;border-radius:8px;font-weight:600} | |
| .nav-section{font-size:10px;font-weight:700;color:#444;text-transform:uppercase;letter-spacing:0.08em;padding:12px 12px 4px} | |
| .ant-layout-sider-footer{padding:10px;border-top:1px solid #1a1a1a} | |
| .logout-btn{width:100%;padding:7px;border:1px solid #1a1a1a;border-radius:7px;background:none;color:#555;font-family:inherit;font-size:11px;font-weight:600;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:6px} | |
| .logout-btn:hover{background:rgba(239,68,68,0.08);border-color:rgba(239,68,68,0.2);color:#ef4444} | |
| .ant-layout-sider-trigger{height:44px;background:#111929;color:rgba(255,255,255,0.45);display:flex;align-items:center;justify-content:center;cursor:pointer;border-top:1px solid #1a1a1a;font-size:12px;gap:8px;transition:all .2s} | |
| .ant-layout-sider-trigger:hover{color:#fff} | |
| #content-layout{flex:1;margin-left:200px;overflow-y:auto;padding:20px 20px 48px} | |
| .page{display:none;animation:pageIn .3s ease} | |
| .page.active{display:block} | |
| @keyframes pageIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} | |
| .ant-card{background:#151f31;border:1px solid #1a1a1a;border-radius:12px;padding:16px;margin-bottom:12px} | |
| .ant-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.3)} | |
| .ant-card-head{display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid #1a1a1a;margin-bottom:12px} | |
| .ant-card-head-title{font-size:14px;font-weight:600;color:rgba(255,255,255,0.85)} | |
| .ant-card-extra{color:rgba(255,255,255,0.45);font-size:13px} | |
| .ant-row{display:flex;flex-wrap:wrap;margin:-6px -8px} | |
| .ant-col{padding:6px 8px;flex:0 0 auto} | |
| .ant-col-12{width:50%}.ant-col-sm-12{width:50%}.ant-col-md-12{width:50%}.ant-col-sm-24{width:100%}.ant-col-md-6{width:25%} | |
| .ant-statistic-title{font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:4px} | |
| .ant-statistic-content{font-size:16px;color:rgba(255,255,255,0.85);display:flex;align-items:center;gap:6px} | |
| .ant-statistic-content-prefix{color:rgba(255,255,255,0.35)} | |
| .ant-tag{display:inline-flex;align-items:center;padding:2px 10px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;margin:2px} | |
| .ant-tag-green{background:rgba(0,135,113,0.15);color:#008771;border:1px solid rgba(0,135,113,0.2)} | |
| .ant-tag-orange{background:rgba(255,160,49,0.15);color:#ffa031;border:1px solid rgba(255,160,49,0.2)} | |
| .ant-tag-purple{background:rgba(217,136,205,0.15);color:#d988cd;border:1px solid rgba(217,136,205,0.2)} | |
| .ant-tag-red{background:rgba(239,68,68,0.1);color:#ef4444;border:1px solid rgba(239,68,68,0.15)} | |
| .ant-badge-status{display:inline-flex;align-items:center;gap:6px} | |
| .ant-badge-status-dot{width:8px;height:8px;border-radius:50%;display:inline-block} | |
| .ant-badge-status-green{background:#22c55e} | |
| .ant-badge-status-text{font-size:12px;color:rgba(255,255,255,0.75)} | |
| .ant-badge-status-processing{animation:pulse 1.2s ease-in-out infinite} | |
| @keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.4}} | |
| .ant-btn{font-family:inherit;font-size:12px;font-weight:600;border-radius:8px;padding:7px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;border:none;transition:all .15s} | |
| .ant-btn-primary{background:#008771;color:#fff} | |
| .ant-btn-primary:hover{background:#006b5a} | |
| .ant-btn-secondary{background:#1a1a1a;color:#888;border:1px solid #2a2a2a} | |
| .ant-btn-secondary:hover{border-color:#008771;color:#008771} | |
| .ant-btn-danger{background:rgba(239,68,68,0.1);color:#ef4444;border:1px solid rgba(239,68,68,0.12)} | |
| .ant-btn-danger:hover{background:rgba(239,68,68,0.2)} | |
| .ant-btn-sm{padding:4px 10px;font-size:11px} | |
| .sys-bar{height:6px;background:#1a1a1a;border-radius:3px;overflow:hidden;margin-top:8px} | |
| .sys-bar-fill{height:100%;border-radius:3px;transition:width .5s} | |
| .ant-table-wrapper{overflow-x:auto;margin-top:4px} | |
| .ant-table{width:100%;border-collapse:collapse;font-size:13px} | |
| .ant-table th{text-align:left;font-size:11px;font-weight:600;color:#555;padding:10px 12px;text-transform:uppercase;border-bottom:1px solid #1a1a1a;background:#111} | |
| .ant-table td{padding:10px 12px;border-bottom:1px solid #1a1a1a;vertical-align:middle;color:#ccc} | |
| .ant-table tr:last-child td{border-bottom:none} | |
| .ant-table tbody tr:hover td{background:rgba(0,135,113,0.04)} | |
| .toggle{width:34px;height:18px;border-radius:10px;background:#2a2a2a;position:relative;cursor:pointer;transition:all .3s;border:1px solid #333;flex-shrink:0} | |
| .toggle.on{background:#22c55e;border-color:#22c55e;box-shadow:0 0 12px rgba(34,197,94,0.3)} | |
| .toggle::after{content:'';position:absolute;width:12px;height:12px;border-radius:50%;background:#888;top:2px;left:2px;transition:all .3s} | |
| .toggle.on::after{left:16px;background:#fff} | |
| .usage-pill{display:flex;align-items:center;gap:8px;padding:2px 10px;border-radius:999px;background:#1a1a1a;font-size:11px;color:#888} | |
| .usage-pill .used{color:#fff;font-weight:600} | |
| .usage-pill .bar{flex:1;height:4px;background:#2a2a2a;border-radius:2px;min-width:50px} | |
| .usage-pill .fill{height:100%;border-radius:2px;transition:width .3s} | |
| .usage-pill .limit{color:#555} | |
| .btn-copy{background:rgba(0,135,113,0.1);color:#008771;border:1px solid rgba(0,135,113,0.15);font-family:inherit;font-size:11px;font-weight:600;border-radius:8px;padding:4px 10px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:all .15s} | |
| .btn-copy:hover{background:#008771;color:#fff} | |
| .btn-qr{background:rgba(34,197,94,0.1);color:#22c55e;border:1px solid rgba(34,197,94,0.15);font-family:inherit;font-size:11px;font-weight:600;border-radius:8px;padding:4px 10px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:all .15s} | |
| .btn-qr:hover{background:#22c55e;color:#fff} | |
| .search-box{flex:1;min-width:160px;position:relative} | |
| .search-box input{width:100%;padding:8px 12px 8px 32px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;color:#fff;font-size:12px;outline:none;transition:all .2s;font-family:inherit} | |
| .search-box input:focus{border-color:#008771} | |
| .search-box svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#555} | |
| .filter-chips{display:flex;gap:3px;padding:3px 5px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px} | |
| .chip{padding:5px 12px;border-radius:6px;font-size:11px;font-weight:600;color:#666;cursor:pointer;border:none;background:none;transition:all .2s;font-family:inherit} | |
| .chip.active{background:#008771;color:#fff} | |
| .chip:hover:not(.active){background:#2a2a2a;color:#fff} | |
| .inbounds-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap} | |
| .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);background:#151f31;color:#fff;border:1px solid #1a1a1a;border-radius:10px;padding:10px 20px;font-size:12px;font-weight:500;opacity:0;transition:all .3s;z-index:999;display:flex;align-items:center;gap:8px;box-shadow:0 8px 24px rgba(0,0,0,0.4)} | |
| .toast.show{opacity:1;transform:translateX(-50%) translateY(0)} | |
| .toast.error{border-color:rgba(239,68,68,0.3);color:#ef4444} | |
| .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(6px)} | |
| .modal-overlay.show{display:flex} | |
| .modal{background:#151f31;border:1px solid #1a1a1a;border-radius:16px;padding:24px;width:100%;max-width:440px;position:relative;box-shadow:0 20px 60px rgba(0,0,0,0.5)} | |
| .modal-title{font-size:15px;font-weight:700;margin-bottom:16px;color:rgba(255,255,255,0.85)} | |
| .modal-close{position:absolute;top:12px;right:14px;background:#1a1a1a;border:1px solid #2a2a2a;color:#666;width:26px;height:26px;border-radius:6px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;transition:all .2s} | |
| .modal-close:hover{background:rgba(239,68,68,0.1);color:#ef4444} | |
| .form-group{display:flex;flex-direction:column;gap:5px;margin-bottom:12px} | |
| .form-label{font-size:11px;font-weight:600;color:#888;text-transform:uppercase;letter-spacing:0.04em} | |
| .form-input,.form-select{padding:8px 12px;border-radius:8px;border:1px solid #2a2a2a;font-family:inherit;font-size:13px;outline:none;color:#fff;background:#1a1a1a;transition:all .2s;width:100%} | |
| .form-input:focus,.form-select:focus{border-color:#008771;box-shadow:0 0 0 3px rgba(0,135,113,0.1)} | |
| .form-select option{background:#1a1a1a;color:#fff} | |
| .form-row{display:flex;gap:8px} | |
| .form-row .form-group{margin-bottom:0;flex:1} | |
| .qr-box{text-align:center;padding:20px;background:#111;border-radius:12px;margin-top:12px} | |
| .qr-box img{max-width:200px;border-radius:8px} | |
| .status-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid #1a1a1a} | |
| .status-row:last-child{border-bottom:none} | |
| .status-key{font-size:12px;color:#888;display:flex;align-items:center;gap:8px} | |
| .status-val{font-size:12px;color:#ccc;font-weight:600} | |
| .empty-state{text-align:center;padding:40px;color:#555} | |
| .mobile-header{display:none;position:fixed;top:0;left:0;right:0;height:44px;background:#111929;border-bottom:1px solid #1a1a1a;z-index:90;align-items:center;justify-content:space-between;padding:0 14px} | |
| .menu-toggle{width:32px;height:32px;border-radius:8px;border:1px solid #1a1a1a;background:#151f31;color:#888;display:flex;align-items:center;justify-content:center;cursor:pointer} | |
| .sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:99} | |
| .sidebar-overlay.show{display:block} | |
| @media(max-width:768px){ | |
| .ant-layout-sider{transform:translateX(-100%);width:200px;z-index:200} | |
| .ant-layout-sider.open{transform:translateX(0);box-shadow:4px 0 20px rgba(0,0,0,0.5)} | |
| #content-layout{margin-left:0;padding-top:56px;padding-left:12px;padding-right:12px} | |
| .mobile-header{display:flex} | |
| .ant-col-md-12{width:100%} | |
| .ant-col-md-6{width:50%} | |
| } | |
| @media(max-width:480px){.ant-col-md-6{width:100%}} | |
| /* ββ Overview (NyxRelay) ββ */ | |
| .gauge-row{display:flex;gap:2px;align-items:flex-start} | |
| .gauge{flex:1;min-width:0;text-align:center;padding:2px 0} | |
| .gauge-svg{width:100%;max-width:118px;height:auto} | |
| .gauge-pct{font-size:16px;font-weight:700;fill:rgba(255,255,255,0.85)} | |
| .gauge-arc{transition:stroke-dasharray .6s ease} | |
| .gauge-label{margin-top:2px;font-size:11px;line-height:1.35} | |
| .gl-title{color:rgba(255,255,255,0.78);font-weight:600;display:block} | |
| .gl-sub{color:rgba(255,255,255,0.45);display:block;font-size:10.5px} | |
| .split-row{display:flex;align-items:stretch} | |
| .split-col{flex:1;display:flex;align-items:center;justify-content:center;gap:7px;padding:11px 6px;background:none;border:none;color:rgba(255,255,255,0.7);font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s} | |
| .split-col + .split-col{border-left:1px solid #1a2536} | |
| button.split-col:hover{color:#2bd4a0;background:rgba(43,212,160,0.06)} | |
| .split-col svg{flex-shrink:0;opacity:.85} | |
| .tag-row{display:flex;flex-wrap:wrap;gap:6px;padding-top:2px} | |
| .tag-row .ant-tag{font-size:11px;text-transform:none;font-weight:600;padding:3px 11px;border-radius:6px} | |
| .update-btn{display:inline-flex;align-items:center;gap:6px;font-family:inherit;font-size:11px;font-weight:600;color:#ffa031;background:rgba(255,160,49,0.1);border:1px solid rgba(255,160,49,0.25);border-radius:7px;padding:5px 10px;cursor:pointer;transition:all .15s} | |
| .update-btn:hover{background:rgba(255,160,49,0.18)} | |
| .eye-btn{background:none;border:none;color:rgba(255,255,255,0.45);cursor:pointer;padding:2px;display:flex;transition:color .15s} | |
| .eye-btn:hover{color:#fff} | |
| .ip-val{font-family:monospace;font-size:13px;color:rgba(255,255,255,0.85);transition:filter .2s} | |
| .ip-val.ip-hidden{filter:blur(6px);user-select:none} | |
| .spd-up{color:#2bd4a0}.spd-down{color:#3b9dff} | |
| .data-stat{display:flex;align-items:center;gap:6px} | |
| .data-stat svg{color:rgba(255,255,255,0.4)} | |
| .log-box{background:#0b1220;border:1px solid #1a2536;border-radius:8px;padding:12px;font-family:monospace;font-size:11.5px;color:#9fb3c8;max-height:50vh;overflow:auto;white-space:pre-wrap;word-break:break-word;line-height:1.6} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="toast" id="toast"></div> | |
| <div class="mobile-header"> | |
| <span style="font-weight:700;font-size:13px;color:#fff">NyxRelay</span> | |
| <button class="menu-toggle" onclick="toggleSidebar()">☰</button> | |
| </div> | |
| <div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div> | |
| <aside class="ant-layout-sider" id="sidebar"> | |
| <div class="ant-layout-sider-children"> | |
| <div class="brand-title"> | |
| <svg width="26" height="26" viewBox="0 0 56 56" fill="none"> | |
| <rect width="56" height="56" rx="14" fill="url(#lg)"/> | |
| <circle cx="28" cy="28" r="14" stroke="#fff" stroke-width="1.5" opacity="0.3"/> | |
| <circle cx="28" cy="18" r="3.5" fill="#fff"/> | |
| <circle cx="19" cy="33" r="3.5" fill="#fff"/> | |
| <circle cx="37" cy="33" r="3.5" fill="#fff"/> | |
| <line x1="28" y1="21.5" x2="21" y2="30" stroke="#fff" stroke-width="1.5" opacity="0.8"/> | |
| <line x1="28" y1="21.5" x2="35" y2="30" stroke="#fff" stroke-width="1.5" opacity="0.8"/> | |
| <line x1="22.5" y1="33" x2="33.5" y2="33" stroke="#fff" stroke-width="1.5" opacity="0.8"/> | |
| <circle cx="28" cy="28" r="2" fill="#fff" opacity="0.9"/> | |
| <defs><linearGradient id="lg" x1="0" y1="0" x2="56" y2="56"><stop stop-color="#008771"/><stop offset="1" stop-color="#005565"/></linearGradient></defs> | |
| </svg> | |
| <span>NyxRelay <span class="version">v1.0</span></span> | |
| </div> | |
| <div class="nav-section">Main</div> | |
| <ul class="ant-menu"> | |
| <li class="ant-menu-item ant-menu-item-selected" onclick="switchPage('overview',this)"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg> | |
| <span>Overview</span> | |
| </li> | |
| <li class="ant-menu-item" onclick="switchPage('inbounds',this)"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg> | |
| <span>Inbounds</span> | |
| <span class="nav-badge" id="links-badge">0</span> | |
| </li> | |
| <li class="ant-menu-item" onclick="switchPage('cleanip',this)"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg> | |
| <span>Clean IP</span> | |
| </li> | |
| <li class="ant-menu-item" onclick="switchPage('domain',this)"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> | |
| <span>Domain</span> | |
| </li> | |
| </ul> | |
| <div class="nav-section">System</div> | |
| <ul class="ant-menu"> | |
| <li class="ant-menu-item" onclick="switchPage('settings',this)"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg> | |
| <span>Settings</span> | |
| </li> | |
| </ul> | |
| </div> | |
| <div class="ant-layout-sider-footer"> | |
| <button class="logout-btn" onclick="doLogout()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> | |
| Log Out | |
| </button> | |
| </div> | |
| <div class="ant-layout-sider-trigger" onclick="toggleSidebar()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg> | |
| <span style="font-size:11px">Collapse</span> | |
| </div> | |
| </aside> | |
| <section id="content-layout"> | |
| <!-- ββ OVERVIEW ββ --> | |
| <div class="page active" id="page-overview"> | |
| <!-- Gauges --> | |
| <div class="ant-card"> | |
| <svg width="0" height="0" style="position:absolute"><defs> | |
| <linearGradient id="gaugeGrad" x1="0" y1="1" x2="1" y2="0"> | |
| <stop offset="0" stop-color="#0e9f6e"/><stop offset="1" stop-color="#2bd4a0"/> | |
| </linearGradient></defs></svg> | |
| <div class="gauge-row"> | |
| <div class="gauge"> | |
| <svg viewBox="0 0 100 100" class="gauge-svg"> | |
| <path class="gauge-track" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="#1f2a3d" stroke-width="7" | |
| stroke-dasharray="197.9 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <path id="cpu-arc" class="gauge-arc" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="url(#gaugeGrad)" stroke-width="7" | |
| stroke-dasharray="0 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <text id="cpu-text" x="50" y="54" text-anchor="middle" class="gauge-pct">0%</text> | |
| </svg> | |
| <div class="gauge-label"><span class="gl-title">CPU: <span id="cpu-cores">--</span></span><span id="cpu-label" class="gl-sub"></span></div> | |
| </div> | |
| <div class="gauge"> | |
| <svg viewBox="0 0 100 100" class="gauge-svg"> | |
| <path class="gauge-track" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="#1f2a3d" stroke-width="7" | |
| stroke-dasharray="197.9 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <path id="ram-arc" class="gauge-arc" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="url(#gaugeGrad)" stroke-width="7" | |
| stroke-dasharray="0 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <text id="ram-text" x="50" y="54" text-anchor="middle" class="gauge-pct">0%</text> | |
| </svg> | |
| <div class="gauge-label"><span class="gl-title">RAM:</span><span id="ram-label" class="gl-sub"></span></div> | |
| </div> | |
| <div class="gauge"> | |
| <svg viewBox="0 0 100 100" class="gauge-svg"> | |
| <path class="gauge-track" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="#1f2a3d" stroke-width="7" | |
| stroke-dasharray="197.9 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <path id="swap-arc" class="gauge-arc" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="url(#gaugeGrad)" stroke-width="7" | |
| stroke-dasharray="0 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <text id="swap-text" x="50" y="54" text-anchor="middle" class="gauge-pct">0%</text> | |
| </svg> | |
| <div class="gauge-label"><span class="gl-title">Swap:</span><span id="swap-label" class="gl-sub"></span></div> | |
| </div> | |
| <div class="gauge"> | |
| <svg viewBox="0 0 100 100" class="gauge-svg"> | |
| <path class="gauge-track" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="#1f2a3d" stroke-width="7" | |
| stroke-dasharray="197.9 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <path id="storage-arc" class="gauge-arc" d="M 50 50 m 0 -42 a 42 42 0 1 1 0 84 a 42 42 0 1 1 0 -84" | |
| fill="none" stroke="url(#gaugeGrad)" stroke-width="7" | |
| stroke-dasharray="0 263.9" transform="rotate(135 50 50)" stroke-linecap="round"/> | |
| <text id="storage-text" x="50" y="54" text-anchor="middle" class="gauge-pct">0%</text> | |
| </svg> | |
| <div class="gauge-label"><span class="gl-title">Storage:</span><span id="storage-label" class="gl-sub"></span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Xray (core) --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"> | |
| <div class="ant-card-head-title">Xray</div> | |
| <div class="ant-card-extra"> | |
| <span class="ant-badge-status" id="xray-badge"> | |
| <span class="ant-badge-status-dot ant-badge-status-green ant-badge-status-processing"></span> | |
| <span class="ant-badge-status-text" id="xray-status">Running</span> | |
| </span> | |
| </div> | |
| </div> | |
| <div class="split-row"> | |
| <button class="split-col" onclick="stopService()"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/></svg> | |
| <span id="xray-action-1">Stop</span> | |
| </button> | |
| <button class="split-col" onclick="restartService()"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> | |
| <span>Restart</span> | |
| </button> | |
| <div class="split-col" style="cursor:default"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3"/></svg> | |
| <span id="core-version">{CORE}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Manage --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Manage</div></div> | |
| <div class="split-row"> | |
| <button class="split-col" onclick="openLogs()"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> | |
| <span>Logs</span> | |
| </button> | |
| <button class="split-col" onclick="openConfig()"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg> | |
| <span>Config</span> | |
| </button> | |
| <button class="split-col" onclick="downloadBackup()"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> | |
| <span>Backup</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- NyxRelay --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"> | |
| <div class="ant-card-head-title">NyxRelay</div> | |
| <button class="update-btn" onclick="checkUpdate()"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 01-9 9c-2.5 0-4.78-1-6.43-2.6L3 16"/><path d="M3 12a9 9 0 019-9c2.5 0 4.78 1 6.43 2.6L21 8"/><polyline points="21 3 21 8 16 8"/><polyline points="3 21 3 16 8 16"/></svg> | |
| <span id="panel-version-btn">{PANEL}</span> Update Panel | |
| </button> | |
| </div> | |
| <div class="tag-row"> | |
| <span class="ant-tag ant-tag-green" id="panel-version-tag">{PANEL}</span> | |
| <span class="ant-tag ant-tag-green" id="tg-tag">@NyxRelay</span> | |
| <span class="ant-tag ant-tag-purple">Documentation</span> | |
| </div> | |
| </div> | |
| <!-- Uptime --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Uptime</div></div> | |
| <div class="tag-row"> | |
| <span class="ant-tag ant-tag-green">Xray: <span id="xray-uptime">--</span></span> | |
| <span class="ant-tag ant-tag-green">OS: <span id="os-uptime">--</span></span> | |
| </div> | |
| </div> | |
| <!-- System Load --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">System Load</div></div> | |
| <div class="tag-row"><span class="ant-tag ant-tag-green" id="system-load">--</span></div> | |
| </div> | |
| <!-- Usage --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Usage</div></div> | |
| <div class="tag-row"> | |
| <span class="ant-tag ant-tag-green">RAM: <span id="app-ram">--</span></span> | |
| <span class="ant-tag ant-tag-green">Threads: <span id="threads">--</span></span> | |
| </div> | |
| </div> | |
| <!-- Overall Speed --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Overall Speed</div></div> | |
| <div class="ant-row"> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">Upload</div> | |
| <div class="ant-statistic-content"><span class="spd-up">↑</span> <span id="upload-speed">-- B/s</span></div> | |
| </div> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">Download</div> | |
| <div class="ant-statistic-content"><span class="spd-down">↓</span> <span id="download-speed">-- B/s</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Total Data --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Total Data</div></div> | |
| <div class="ant-row"> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">Sent</div> | |
| <div class="ant-statistic-content data-stat"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z"/><polyline points="8 12 12 8 16 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg> <span id="total-sent">--</span></div> | |
| </div> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">Received</div> | |
| <div class="ant-statistic-content data-stat"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z"/><polyline points="8 12 12 16 16 12"/><line x1="12" y1="8" x2="12" y2="16"/></svg> <span id="total-received">--</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- IP Addresses --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"> | |
| <div class="ant-card-head-title">IP Addresses</div> | |
| <button class="eye-btn" id="ip-eye" onclick="toggleIps()" title="Show / hide"> | |
| <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg> | |
| </button> | |
| </div> | |
| <div class="ant-row"> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">IPv4</div> | |
| <div class="ant-statistic-content"><span id="ipv4" class="ip-val ip-hidden">--</span></div> | |
| </div> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">IPv6</div> | |
| <div class="ant-statistic-content"><span id="ipv6" class="ip-val ip-hidden" style="font-size:11px;word-break:break-all">--</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Connection Stats --> | |
| <div class="ant-card"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Connection Stats</div></div> | |
| <div class="ant-row"> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">TCP</div> | |
| <div class="ant-statistic-content">⇄ <span id="tcp-conn">--</span></div> | |
| </div> | |
| <div class="ant-col ant-col-12"> | |
| <div class="ant-statistic-title">UDP</div> | |
| <div class="ant-statistic-content">⇄ <span id="udp-conn">--</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ INBOUNDS ββ --> | |
| <div class="page" id="page-inbounds"> | |
| <!-- Stats row --> | |
| <div class="ant-row" style="margin:-6px -8px;margin-bottom:4px"> | |
| <div class="ant-col ant-col-md-6 ant-col-sm-12"><div class="ant-card"><div class="ant-statistic"><div class="ant-statistic-title">Sent / Received</div><div class="ant-statistic-content" style="font-size:12px"><span id="ib-sent-recv">-- / --</span></div></div></div></div> | |
| <div class="ant-col ant-col-md-6 ant-col-sm-12"><div class="ant-card"><div class="ant-statistic"><div class="ant-statistic-title">Proxy Traffic</div><div class="ant-statistic-content" style="font-size:14px"><span id="ib-proxy-traffic">-- MB</span></div></div></div></div> | |
| <div class="ant-col ant-col-md-6 ant-col-sm-12"><div class="ant-card"><div class="ant-statistic"><div class="ant-statistic-title">Total Inbounds</div><div class="ant-statistic-content" style="font-size:18px"><span id="ib-total">0</span></div></div></div></div> | |
| <div class="ant-col ant-col-md-6 ant-col-sm-12"><div class="ant-card"><div class="ant-statistic"><div class="ant-statistic-title">Active Clients</div><div class="ant-statistic-content" style="font-size:18px"><span id="ib-clients">0</span></div></div></div></div> | |
| </div> | |
| <div class="ant-card" style="padding:0;overflow:hidden"> | |
| <div class="ant-card-head" style="padding:14px 16px"> | |
| <div class="ant-card-head-title"> | |
| <div style="display:flex;align-items:center;gap:8px"> | |
| <button class="ant-btn ant-btn-primary" onclick="showAddModal()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| Add Inbound | |
| </button> | |
| <button class="ant-btn ant-btn-secondary" onclick="loadInbounds()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="padding:0 16px 8px"> | |
| <div class="inbounds-toolbar"> | |
| <div class="search-box"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> | |
| <input id="inbound-search" placeholder="Search by name..." oninput="filterInbounds()"> | |
| </div> | |
| <div class="filter-chips"> | |
| <button class="chip active" onclick="setFilter('all',this)">All</button> | |
| <button class="chip" onclick="setFilter('active',this)">Active</button> | |
| <button class="chip" onclick="setFilter('disabled',this)">Disabled</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="ant-table-wrapper" style="padding:0 8px 8px"> | |
| <table class="ant-table"> | |
| <thead><tr> | |
| <th style="width:36px">#</th> | |
| <th>Remark</th> | |
| <th style="width:64px">Type</th> | |
| <th>Traffic</th> | |
| <th style="width:72px">IPs</th> | |
| <th style="width:60px">Status</th> | |
| <th style="width:120px">Actions</th> | |
| </tr></thead> | |
| <tbody id="inbounds-tbody"><tr><td colspan="7" style="text-align:center;padding:32px;color:#555">Loading...</td></tr></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ CLEAN IP ββ --> | |
| <div class="page" id="page-cleanip"> | |
| <div class="ant-card" style="max-width:520px"> | |
| <div class="ant-card-head"> | |
| <div class="ant-card-head-title">Clean IP / Addresses</div> | |
| <button class="ant-btn ant-btn-primary ant-btn-sm" onclick="showAddAddressModal()">+ Add</button> | |
| </div> | |
| <div style="font-size:11px;color:#555;margin-bottom:10px">IPs and domains for subscription configs. Default: www.speedtest.net</div> | |
| <div id="address-list"></div> | |
| </div> | |
| </div> | |
| <!-- ββ DOMAIN ββ --> | |
| <div class="page" id="page-domain"> | |
| <div class="ant-card" style="max-width:480px"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Custom Domain</div></div> | |
| <div class="status-row"> | |
| <span class="status-key">Render Domain</span> | |
| <span class="status-val" id="render-domain" style="font-family:monospace;font-size:11px">--</span> | |
| </div> | |
| <div class="status-row"> | |
| <span class="status-key">Custom Domain</span> | |
| <span class="status-val" style="display:flex;align-items:center;gap:8px"> | |
| <span id="domain-value" style="font-family:monospace;font-size:11px">None set</span> | |
| <button class="ant-btn ant-btn-danger ant-btn-sm" id="domain-clear-btn" onclick="clearDomain()" style="display:none">Clear</button> | |
| </span> | |
| </div> | |
| <div class="form-group" style="margin-top:14px"> | |
| <label class="form-label">Set New Domain</label> | |
| <div style="display:flex;gap:8px"> | |
| <input class="form-input" id="domain-input" placeholder="example.com" style="flex:1"> | |
| <button class="ant-btn ant-btn-primary" onclick="saveDomain()">Save</button> | |
| </div> | |
| </div> | |
| <div style="padding:10px;background:rgba(0,135,113,0.06);border:1px solid rgba(0,135,113,0.15);border-radius:8px;font-size:11px;color:#888;line-height:1.6;margin-top:4px"> | |
| 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. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ SETTINGS ββ --> | |
| <div class="page" id="page-settings"> | |
| <div class="ant-card" style="max-width:440px"> | |
| <div class="ant-card-head"><div class="ant-card-head-title">Change Password</div></div> | |
| <div class="form-group"> | |
| <label class="form-label">Current Password</label> | |
| <input class="form-input" type="password" id="cur-pw" placeholder="Current password"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">New Password</label> | |
| <input class="form-input" type="password" id="new-pw" placeholder="Min 4 characters"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Confirm Password</label> | |
| <input class="form-input" type="password" id="confirm-pw" placeholder="Repeat new password"> | |
| </div> | |
| <button class="ant-btn ant-btn-primary" onclick="changePassword()" style="width:100%;justify-content:center;margin-top:4px">Update Password</button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Add Inbound Modal --> | |
| <div class="modal-overlay" id="add-modal" onclick="if(event.target===this)closeModal('add-modal')"> | |
| <div class="modal"> | |
| <button class="modal-close" onclick="closeModal('add-modal')">Γ</button> | |
| <div class="modal-title">Add Inbound</div> | |
| <div class="form-group"> | |
| <label class="form-label">Remark / Name</label> | |
| <input class="form-input" id="new-label" placeholder="e.g. User1"> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label class="form-label">Traffic Limit</label> | |
| <input class="form-input" id="new-limit" type="number" min="0" step="0.1" placeholder="0 = Unlimited"> | |
| </div> | |
| <div class="form-group" style="max-width:90px"> | |
| <label class="form-label">Unit</label> | |
| <select class="form-select" id="new-unit"><option value="GB">GB</option><option value="MB">MB</option></select> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Max IPs (0 = Unlimited)</label> | |
| <input class="form-input" id="new-maxconn" type="number" min="0" step="1" placeholder="0"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Expiry Days (0 = Never)</label> | |
| <input class="form-input" id="new-expiry" type="number" min="0" step="1" placeholder="0"> | |
| </div> | |
| <button class="ant-btn ant-btn-primary" onclick="createInbound()" style="width:100%;justify-content:center;margin-top:8px">Create</button> | |
| </div> | |
| </div> | |
| <!-- Edit Inbound Modal --> | |
| <div class="modal-overlay" id="edit-modal" onclick="if(event.target===this)closeModal('edit-modal')"> | |
| <div class="modal"> | |
| <button class="modal-close" onclick="closeModal('edit-modal')">Γ</button> | |
| <div class="modal-title" id="edit-title">Edit Inbound</div> | |
| <input type="hidden" id="edit-uid"> | |
| <div class="form-group"> | |
| <label class="form-label">Name (read-only)</label> | |
| <input class="form-input" id="edit-name" readonly style="opacity:0.5;cursor:not-allowed"> | |
| </div> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label class="form-label">Traffic Limit</label> | |
| <input class="form-input" id="edit-limit" type="number" min="0" step="0.1" placeholder="0 = Unlimited"> | |
| </div> | |
| <div class="form-group" style="max-width:90px"> | |
| <label class="form-label">Unit</label> | |
| <select class="form-select" id="edit-unit"><option value="GB">GB</option><option value="MB">MB</option></select> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Max IPs</label> | |
| <input class="form-input" id="edit-maxconn" type="number" min="0" step="1" placeholder="0 = Unlimited"> | |
| </div> | |
| <div style="display:flex;gap:8px;margin-top:12px"> | |
| <button class="ant-btn ant-btn-primary" onclick="saveEdit()" style="flex:1;justify-content:center">Save</button> | |
| <button class="ant-btn ant-btn-danger" onclick="resetTraffic()">Reset Traffic</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- QR Modal --> | |
| <div class="modal-overlay" id="qr-modal" onclick="if(event.target===this)closeModal('qr-modal')"> | |
| <div class="modal" style="max-width:320px"> | |
| <button class="modal-close" onclick="closeModal('qr-modal')">Γ</button> | |
| <div class="modal-title">QR Code</div> | |
| <div class="qr-box"><img id="qr-img" src="" alt="QR Code" style="max-width:100%;border-radius:8px"></div> | |
| <div style="display:flex;gap:8px;margin-top:14px"> | |
| <button class="ant-btn ant-btn-primary" onclick="downloadQR()" style="flex:1;justify-content:center">Download</button> | |
| <button class="ant-btn ant-btn-secondary" onclick="closeModal('qr-modal')" style="flex:1;justify-content:center">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add Address Modal --> | |
| <div class="modal-overlay" id="add-address-modal" onclick="if(event.target===this)closeModal('add-address-modal')"> | |
| <div class="modal"> | |
| <button class="modal-close" onclick="closeModal('add-address-modal')">Γ</button> | |
| <div class="modal-title">Add Clean IP / Domain</div> | |
| <div class="form-group"> | |
| <label class="form-label">IPs or Domains (one per line)</label> | |
| <textarea class="form-input" id="new-address" rows="5" placeholder="8.8.8.8 example.com 1.0.0.1" style="resize:vertical;font-family:monospace"></textarea> | |
| </div> | |
| <button class="ant-btn ant-btn-primary" onclick="addAddresses()" style="width:100%;justify-content:center;margin-top:8px">Add All</button> | |
| </div> | |
| </div> | |
| <!-- Logs Modal --> | |
| <div class="modal-overlay" id="logs-modal" onclick="if(event.target===this)closeModal('logs-modal')"> | |
| <div class="modal" style="max-width:560px"> | |
| <button class="modal-close" onclick="closeModal('logs-modal')">Γ</button> | |
| <div class="modal-title">Logs</div> | |
| <div class="log-box" id="logs-box">Loading...</div> | |
| <div style="display:flex;gap:8px;margin-top:14px"> | |
| <button class="ant-btn ant-btn-secondary" onclick="openLogs()" style="flex:1;justify-content:center">Refresh</button> | |
| <button class="ant-btn ant-btn-secondary" onclick="closeModal('logs-modal')" style="flex:1;justify-content:center">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Config Modal --> | |
| <div class="modal-overlay" id="config-modal" onclick="if(event.target===this)closeModal('config-modal')"> | |
| <div class="modal" style="max-width:560px"> | |
| <button class="modal-close" onclick="closeModal('config-modal')">Γ</button> | |
| <div class="modal-title">Config</div> | |
| <div class="log-box" id="config-box">Loading...</div> | |
| <div style="display:flex;gap:8px;margin-top:14px"> | |
| <button class="ant-btn ant-btn-secondary" onclick="closeModal('config-modal')" style="flex:1;justify-content:center">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let allLinks = []; | |
| let currentFilter = 'all'; | |
| let apiStats = {}; | |
| // ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function switchPage(id, el) { | |
| document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); | |
| document.getElementById('page-' + id).classList.add('active'); | |
| document.querySelectorAll('.ant-menu-item').forEach(n => n.classList.remove('ant-menu-item-selected')); | |
| if (el) el.classList.add('ant-menu-item-selected'); | |
| closeSidebar(); | |
| if (id === 'inbounds') loadInbounds(); | |
| if (id === 'cleanip') loadAddresses(); | |
| if (id === 'domain') loadDomain(); | |
| } | |
| function toggleSidebar() { | |
| const s = document.getElementById('sidebar'); | |
| const ov = document.getElementById('sidebar-overlay'); | |
| s.classList.toggle('open'); | |
| ov.classList.toggle('show'); | |
| } | |
| function closeSidebar() { | |
| document.getElementById('sidebar').classList.remove('open'); | |
| document.getElementById('sidebar-overlay').classList.remove('show'); | |
| } | |
| function closeModal(id) { document.getElementById(id).classList.remove('show'); } | |
| function showAddModal() { document.getElementById('add-modal').classList.add('show'); } | |
| function toast(msg, err=false) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.className = 'toast' + (err ? ' error' : '') + ' show'; | |
| setTimeout(() => t.classList.remove('show'), 3000); | |
| } | |
| function esc(s) { return String(s).replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } | |
| function fmtBytes(b) { | |
| if (!b || b === 0) return '0 B'; | |
| if (b >= 1073741824) return (b/1073741824).toFixed(2)+' GB'; | |
| if (b >= 1048576) return (b/1048576).toFixed(2)+' MB'; | |
| if (b >= 1024) return (b/1024).toFixed(1)+' KB'; | |
| return b+' B'; | |
| } | |
| function fmtLimit(b) { | |
| if (!b || b === 0) return 'Unlimited'; | |
| const gb = b/1073741824; | |
| return (gb%1===0?gb.toFixed(0):gb.toFixed(1))+' GB'; | |
| } | |
| // ββ Stats ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let ipsVisible = false; | |
| function setGauge(prefix, pct){ | |
| pct = Math.max(0, Math.min(100, pct||0)); | |
| const FULL = 197.9; // length of the 270deg track | |
| const arc = document.getElementById(prefix+'-arc'); | |
| const txt = document.getElementById(prefix+'-text'); | |
| if (arc) arc.setAttribute('stroke-dasharray', (pct/100*FULL).toFixed(1)+' 263.9'); | |
| if (txt) txt.textContent = pct.toFixed(2)+'%'; | |
| } | |
| async function loadStats() { | |
| try { | |
| const r = await fetch('/api/stats'); | |
| if (!r.ok) throw new Error(); | |
| apiStats = await r.json(); | |
| // Gauges | |
| setGauge('cpu', apiStats.cpuUsage||0); | |
| setGauge('ram', apiStats.ramUsage||0); | |
| setGauge('swap', apiStats.swapUsage||0); | |
| setGauge('storage', apiStats.storageUsage||0); | |
| const cores = apiStats.cpuCores||1; | |
| document.getElementById('cpu-cores').textContent = cores + ' Core' + (cores>1?'s':''); | |
| document.getElementById('ram-label').textContent = (apiStats.ramUsed||'')+' / '+(apiStats.ramTotal||''); | |
| document.getElementById('swap-label').textContent = (apiStats.swapUsed||'0 B')+' / '+(apiStats.swapTotal||'0 B'); | |
| document.getElementById('storage-label').textContent = (apiStats.storageUsed||'')+' / '+(apiStats.storageTotal||''); | |
| // Xray status | |
| const running = apiStats.xrayRunning !== false; | |
| const dot = document.querySelector('#xray-badge .ant-badge-status-dot'); | |
| document.getElementById('xray-status').textContent = running ? 'Running' : 'Stopped'; | |
| document.getElementById('xray-action-1').textContent = running ? 'Stop' : 'Start'; | |
| if (dot) { | |
| dot.classList.toggle('ant-badge-status-green', running); | |
| dot.classList.toggle('ant-badge-status-processing', running); | |
| dot.style.background = running ? '' : '#ef4444'; | |
| } | |
| document.getElementById('core-version').textContent = apiStats.coreVersion || '--'; | |
| const pv = apiStats.panelVersion || '--'; | |
| document.getElementById('panel-version-btn').textContent = pv; | |
| document.getElementById('panel-version-tag').textContent = pv; | |
| if (apiStats.telegram) document.getElementById('tg-tag').textContent = apiStats.telegram; | |
| // Uptime / load / usage | |
| document.getElementById('xray-uptime').textContent = apiStats.xrayUptime || '--'; | |
| document.getElementById('os-uptime').textContent = apiStats.uptime || '--'; | |
| document.getElementById('system-load').textContent = apiStats.systemLoad || '--'; | |
| document.getElementById('app-ram').textContent = apiStats.appRam || '--'; | |
| document.getElementById('threads').textContent = apiStats.threads ?? '--'; | |
| // Speed / data | |
| document.getElementById('upload-speed').textContent = apiStats.uploadSpeed || '-- B/s'; | |
| document.getElementById('download-speed').textContent = apiStats.downloadSpeed || '-- B/s'; | |
| document.getElementById('total-sent').textContent = apiStats.totalSent || '--'; | |
| document.getElementById('total-received').textContent = apiStats.totalReceived || '--'; | |
| // IPs | |
| document.getElementById('ipv4').textContent = apiStats.ipv4 || 'N/A'; | |
| document.getElementById('ipv6').textContent = apiStats.ipv6 || 'N/A'; | |
| // Connection stats | |
| document.getElementById('tcp-conn').textContent = apiStats.tcpConnections ?? '--'; | |
| document.getElementById('udp-conn').textContent = apiStats.udpConnections ?? '--'; | |
| // sidebar badge | |
| const lb = document.getElementById('links-badge'); | |
| if (lb) lb.textContent = apiStats.linksCount || 0; | |
| // inbounds page mirrors | |
| if (document.getElementById('ib-proxy-traffic')) document.getElementById('ib-proxy-traffic').textContent = (apiStats.totalTrafficMb||0).toFixed(2)+' MB'; | |
| if (document.getElementById('ib-total')) document.getElementById('ib-total').textContent = apiStats.linksCount || 0; | |
| if (document.getElementById('ib-sent-recv')) document.getElementById('ib-sent-recv').textContent = (apiStats.totalSent||'--')+' / '+(apiStats.totalReceived||'--'); | |
| const rd = document.getElementById('render-domain'); | |
| if (rd) rd.textContent = apiStats.domain || '--'; | |
| } catch(e) {} | |
| } | |
| function toggleIps(){ | |
| ipsVisible = !ipsVisible; | |
| document.querySelectorAll('.ip-val').forEach(el=>el.classList.toggle('ip-hidden', !ipsVisible)); | |
| } | |
| async function stopService(){ | |
| const running = apiStats.xrayRunning !== false; | |
| if (running){ | |
| if(!confirm('Stop the core? All active connections will be dropped.')) return; | |
| try{ const r=await fetch('/api/service/stop',{method:'POST'}); if(!r.ok)throw 0; toast('Core stopped'); }catch(e){ toast('Error',true);} | |
| } else { | |
| try{ const r=await fetch('/api/service/restart',{method:'POST'}); if(!r.ok)throw 0; toast('Core started'); }catch(e){ toast('Error',true);} | |
| } | |
| await loadStats(); | |
| } | |
| async function restartService(){ | |
| if(!confirm('Restart the core?')) return; | |
| try{ const r=await fetch('/api/service/restart',{method:'POST'}); if(!r.ok)throw 0; toast('Core restarted'); }catch(e){ toast('Error',true);} | |
| await loadStats(); | |
| } | |
| async function openLogs(){ | |
| try{ | |
| const r=await fetch('/api/logs'); const d=await r.json(); | |
| let out='STATUS: '+(d.running?'Running':'Stopped')+'\n'; | |
| out+='Traffic: '+fmtBytes(d.totals.bytes)+' | Requests: '+d.totals.requests+' | Errors: '+d.totals.errors+'\n'; | |
| out+='\nββ Active connections ('+d.connections.length+') ββ\n'; | |
| if(!d.connections.length) out+='(none)\n'; | |
| d.connections.forEach(c=>{ out+=c.ip+' '+(c.uuid||'').slice(0,8)+'β¦ '+fmtBytes(c.bytes)+' '+(c.connected_at||'').slice(11,19)+'\n'; }); | |
| out+='\nββ Recent errors ('+d.errors.length+') ββ\n'; | |
| if(!d.errors.length) out+='(none)\n'; | |
| d.errors.slice().reverse().forEach(e=>{ out+=(e.time||'').slice(11,19)+' '+e.error+'\n'; }); | |
| document.getElementById('logs-box').textContent=out; | |
| document.getElementById('logs-modal').classList.add('show'); | |
| }catch(e){ toast('Failed to load logs',true);} | |
| } | |
| async function openConfig(){ | |
| try{ | |
| const r=await fetch('/api/config'); const d=await r.json(); | |
| document.getElementById('config-box').textContent=JSON.stringify(d,null,2); | |
| document.getElementById('config-modal').classList.add('show'); | |
| }catch(e){ toast('Failed to load config',true);} | |
| } | |
| function downloadBackup(){ | |
| const a=document.createElement('a'); a.href='/api/backup'; a.download=''; document.body.appendChild(a); a.click(); a.remove(); | |
| toast('Backup downloaded'); | |
| } | |
| function checkUpdate(){ | |
| toast('NyxRelay '+(apiStats.panelVersion||'')+' β you are on the latest version'); | |
| } | |
| // ββ Inbounds βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadInbounds() { | |
| try { | |
| const r = await fetch('/api/links'); | |
| if (!r.ok) throw new Error(); | |
| const d = await r.json(); | |
| allLinks = d.links || []; | |
| filterInbounds(); | |
| if (document.getElementById('ib-total')) document.getElementById('ib-total').textContent = allLinks.length; | |
| if (document.getElementById('ib-clients')) document.getElementById('ib-clients').textContent = allLinks.filter(l=>l.active).length; | |
| } catch(e) { | |
| document.getElementById('inbounds-tbody').innerHTML = '<tr><td colspan="7" style="text-align:center;padding:32px;color:#555">Failed to load inbounds</td></tr>'; | |
| } | |
| } | |
| function setFilter(f, el) { | |
| currentFilter = f; | |
| document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); | |
| el.classList.add('active'); | |
| filterInbounds(); | |
| } | |
| function filterInbounds() { | |
| const q = (document.getElementById('inbound-search')?.value||'').toLowerCase(); | |
| let filtered = allLinks; | |
| if (currentFilter === 'active') filtered = filtered.filter(l => l.active); | |
| if (currentFilter === 'disabled') filtered = filtered.filter(l => !l.active); | |
| if (q) filtered = filtered.filter(l => l.label.toLowerCase().includes(q) || l.uuid.toLowerCase().includes(q)); | |
| renderInbounds(filtered); | |
| } | |
| function renderInbounds(links) { | |
| const tbody = document.getElementById('inbounds-tbody'); | |
| if (!links.length) { | |
| tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state">No inbounds found</div></td></tr>'; | |
| return; | |
| } | |
| let idx = links.length; | |
| tbody.innerHTML = links.map(l => { | |
| const u = l.used_bytes, lim = l.limit_bytes; | |
| const uF = fmtBytes(u), lF = fmtLimit(lim); | |
| const pct = lim > 0 ? Math.min(100, (u/lim)*100) : 0; | |
| const col = pct>90?'#ef4444':pct>70?'#fbbf24':'#008771'; | |
| const i = idx--; | |
| const maxC = l.max_connections || 0; | |
| const curC = l.current_connections || 0; | |
| return `<tr> | |
| <td style="color:#555;font-size:11px">${i}</td> | |
| <td style="font-weight:600;color:#ccc">${esc(l.label)}</td> | |
| <td><span class="ant-tag ant-tag-purple">VLESS</span></td> | |
| <td> | |
| <div class="usage-pill"> | |
| <span class="used">${uF}</span> | |
| <div class="bar"><div class="fill" style="width:${pct}%;background:${col}"></div></div> | |
| <span class="limit">${lF}</span> | |
| </div> | |
| </td> | |
| <td style="font-size:12px;font-weight:600;color:${maxC>0&&curC>=maxC?'#ef4444':'#888'}">${curC}/${maxC||'β'}</td> | |
| <td><span class="ant-tag ${l.active?'ant-tag-green':'ant-tag-red'}">${l.active?'On':'Off'}</span></td> | |
| <td> | |
| <div style="display:flex;gap:3px;align-items:center"> | |
| <button class="toggle ${l.active?'on':''}" data-uid="${l.uuid}" onclick="toggleInbound(this)" title="Toggle"></button> | |
| <button class="ant-btn ant-btn-secondary ant-btn-sm" onclick="showEditModal('${l.uuid}')" style="padding:3px 7px;color:#fbbf24;border-color:rgba(251,191,36,0.2);background:rgba(251,191,36,0.06)">e</button> | |
| <button class="btn-copy" onclick="copyVless('${esc(l.vless_link)}')" title="Copy VLESS">c</button> | |
| <button class="btn-copy" onclick="copySubUrl('${l.uuid}')" style="background:rgba(34,197,94,0.08);color:#22c55e;border-color:rgba(34,197,94,0.15)" title="Copy Sub URL">s</button> | |
| <button class="btn-qr" onclick="showQR('${esc(l.vless_link)}')" title="QR">QR</button> | |
| <button class="ant-btn ant-btn-danger ant-btn-sm" onclick="deleteInbound('${l.uuid}')" title="Delete">x</button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| async function toggleInbound(el) { | |
| const uid = el.dataset.uid; | |
| const link = allLinks.find(l => l.uuid === uid); | |
| if (!link) return; | |
| try { | |
| await fetch(`/api/links/${uid}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({active:!link.active})}); | |
| await loadInbounds(); | |
| await loadStats(); | |
| } catch(e) { toast('Error', true); } | |
| } | |
| async function createInbound() { | |
| const label = document.getElementById('new-label').value.trim(); | |
| if (!label) { toast('Name is required', true); return; } | |
| if (!/^[a-zA-Z0-9\-_. ]+$/.test(label)) { toast('Only English letters, numbers, - _ . space', true); return; } | |
| const val = parseFloat(document.getElementById('new-limit').value)||0; | |
| const unit = document.getElementById('new-unit').value; | |
| const maxconn = parseInt(document.getElementById('new-maxconn').value)||0; | |
| const expiry = parseInt(document.getElementById('new-expiry').value)||0; | |
| try { | |
| const r = await fetch('/api/links', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({label,limit_value:val,limit_unit:unit,max_connections:maxconn,expiry_days:expiry})}); | |
| if (!r.ok) { const d=await r.json().catch(()=>({})); throw new Error(d.detail||'Error'); } | |
| toast('Inbound created'); | |
| document.getElementById('new-label').value=''; | |
| document.getElementById('new-limit').value=''; | |
| document.getElementById('new-maxconn').value=''; | |
| document.getElementById('new-expiry').value=''; | |
| closeModal('add-modal'); | |
| await loadInbounds(); | |
| await loadStats(); | |
| } catch(e) { toast(e.message, true); } | |
| } | |
| async function deleteInbound(uid) { | |
| if (!confirm('Delete this inbound?')) return; | |
| try { | |
| await fetch(`/api/links/${uid}`, {method:'DELETE'}); | |
| toast('Deleted'); | |
| await loadInbounds(); | |
| await loadStats(); | |
| } catch(e) { toast('Error', true); } | |
| } | |
| function showEditModal(uid) { | |
| const l = allLinks.find(x => x.uuid === uid); | |
| if (!l) return; | |
| document.getElementById('edit-uid').value = uid; | |
| document.getElementById('edit-name').value = l.label; | |
| const gb = l.limit_bytes / 1073741824; | |
| document.getElementById('edit-limit').value = l.limit_bytes > 0 ? gb.toFixed(2) : ''; | |
| document.getElementById('edit-unit').value = 'GB'; | |
| document.getElementById('edit-maxconn').value = l.max_connections > 0 ? l.max_connections : ''; | |
| document.getElementById('edit-title').textContent = 'Edit: ' + l.label; | |
| document.getElementById('edit-modal').classList.add('show'); | |
| } | |
| async function saveEdit() { | |
| const uid = document.getElementById('edit-uid').value; | |
| const val = parseFloat(document.getElementById('edit-limit').value)||0; | |
| const unit = document.getElementById('edit-unit').value; | |
| const maxconn = parseInt(document.getElementById('edit-maxconn').value)||0; | |
| try { | |
| const r = await fetch(`/api/links/${uid}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({limit_value:val,limit_unit:unit,max_connections:maxconn})}); | |
| if (!r.ok) throw new Error(); | |
| toast('Updated'); | |
| closeModal('edit-modal'); | |
| await loadInbounds(); | |
| } catch(e) { toast('Error', true); } | |
| } | |
| async function resetTraffic() { | |
| const uid = document.getElementById('edit-uid').value; | |
| if (!confirm('Reset traffic to zero?')) return; | |
| try { | |
| await fetch(`/api/links/${uid}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({reset_usage:true})}); | |
| toast('Traffic reset'); | |
| await loadInbounds(); | |
| } catch(e) { toast('Error', true); } | |
| } | |
| function copyVless(txt) { navigator.clipboard.writeText(txt).then(()=>toast('VLESS link copied')).catch(()=>toast('Copy failed',true)); } | |
| async function copySubUrl(uid) { | |
| const url = `https://${location.host}/sub/${uid}`; | |
| navigator.clipboard.writeText(url).then(()=>toast('Subscription URL copied')).catch(()=>toast('Copy failed',true)); | |
| } | |
| function showQR(txt) { | |
| if (!txt) return; | |
| document.getElementById('qr-img').src = 'https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=' + encodeURIComponent(txt); | |
| document.getElementById('qr-modal').classList.add('show'); | |
| } | |
| function downloadQR() { | |
| const img = document.getElementById('qr-img'); | |
| if (!img.src) return; | |
| const a = document.createElement('a'); a.href=img.src; a.download='nyxrelay-qr.png'; a.click(); | |
| } | |
| // ββ Addresses ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let allAddresses = []; | |
| async function loadAddresses() { | |
| try { | |
| const r = await fetch('/api/addresses'); | |
| if (!r.ok) throw new Error(); | |
| const d = await r.json(); | |
| allAddresses = d.addresses || []; | |
| renderAddresses(); | |
| } catch(e) {} | |
| } | |
| function renderAddresses() { | |
| const list = document.getElementById('address-list'); | |
| if (!allAddresses.length) { list.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0">No addresses added</div>'; return; } | |
| list.innerHTML = allAddresses.map((a,i) => ` | |
| <div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#111;border:1px solid #1a1a1a;border-radius:8px;margin-bottom:6px"> | |
| <span style="font-family:monospace;font-size:13px;color:#ccc">${esc(a)}</span> | |
| <button class="ant-btn ant-btn-danger ant-btn-sm" onclick="deleteAddress(${i})">Γ</button> | |
| </div>`).join(''); | |
| } | |
| function showAddAddressModal() { document.getElementById('new-address').value=''; document.getElementById('add-address-modal').classList.add('show'); } | |
| async function addAddresses() { | |
| const text = document.getElementById('new-address').value.trim(); | |
| if (!text) { toast('Enter at least one address', true); return; } | |
| const lines = text.split('\n').map(l=>l.trim()).filter(l=>l); | |
| let added=0, errors=0; | |
| for (const addr of lines) { | |
| if (!/^[a-zA-Z0-9\-_. ]+$/.test(addr)) { errors++; continue; } | |
| try { | |
| const r = await fetch('/api/addresses', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({address:addr})}); | |
| if (r.ok) added++; else errors++; | |
| } catch(e) { errors++; } | |
| } | |
| if (added > 0) toast(`Added ${added} address(es)`); | |
| if (errors > 0) toast(`${errors} failed`, true); | |
| if (added > 0) { closeModal('add-address-modal'); await loadAddresses(); } | |
| } | |
| async function deleteAddress(index) { | |
| if (!confirm('Delete this address?')) return; | |
| try { | |
| const r = await fetch(`/api/addresses/${index}`, {method:'DELETE'}); | |
| if (!r.ok) throw new Error(); | |
| toast('Deleted'); | |
| await loadAddresses(); | |
| } catch(e) { toast('Error', true); } | |
| } | |
| // ββ Domain βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let currentDomain = ''; | |
| async function loadDomain() { | |
| try { | |
| const r = await fetch('/api/domain'); | |
| if (!r.ok) throw new Error(); | |
| const d = await r.json(); | |
| currentDomain = d.domain || ''; | |
| const renderDomain = apiStats.domain || location.host; | |
| const rd = document.getElementById('render-domain'); | |
| if (rd) rd.textContent = renderDomain; | |
| const dv = document.getElementById('domain-value'); | |
| const dcb = document.getElementById('domain-clear-btn'); | |
| if (currentDomain) { | |
| dv.textContent = currentDomain; | |
| dv.style.color = '#008771'; | |
| if (dcb) dcb.style.display = ''; | |
| } else { | |
| dv.textContent = renderDomain + ' (default)'; | |
| dv.style.color = '#888'; | |
| if (dcb) dcb.style.display = 'none'; | |
| } | |
| } catch(e) {} | |
| } | |
| async function saveDomain() { | |
| const domain = document.getElementById('domain-input').value.trim(); | |
| if (!domain) { toast('Enter a domain', true); return; } | |
| try { | |
| const r = await fetch('/api/domain', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({domain})}); | |
| if (!r.ok) { const d=await r.json().catch(()=>({})); throw new Error(d.detail||'Error'); } | |
| toast('Domain saved'); | |
| document.getElementById('domain-input').value = ''; | |
| await loadDomain(); | |
| } catch(e) { toast(e.message, true); } | |
| } | |
| async function clearDomain() { | |
| try { | |
| await fetch('/api/domain', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({domain:''})}); | |
| toast('Domain cleared'); | |
| await loadDomain(); | |
| } catch(e) { toast('Error', true); } | |
| } | |
| // ββ Settings βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function changePassword() { | |
| const cur = document.getElementById('cur-pw').value; | |
| const nw = document.getElementById('new-pw').value; | |
| const conf = document.getElementById('confirm-pw').value; | |
| if (!cur || !nw || !conf) { toast('Fill all fields', true); return; } | |
| if (nw !== conf) { toast('Passwords do not match', true); return; } | |
| if (nw.length < 4) { toast('Min 4 characters', true); return; } | |
| try { | |
| const r = await fetch('/api/change-password', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({current_password:cur,new_password:nw})}); | |
| if (!r.ok) { const d=await r.json().catch(()=>({})); throw new Error(d.detail||'Error'); } | |
| toast('Password updated'); | |
| document.getElementById('cur-pw').value=''; | |
| document.getElementById('new-pw').value=''; | |
| document.getElementById('confirm-pw').value=''; | |
| } catch(e) { toast(e.message, true); } | |
| } | |
| async function doLogout() { | |
| await fetch('/api/logout', {method:'POST'}); | |
| location.href = '/login'; | |
| } | |
| // ββ Init βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| loadStats(); | |
| loadInbounds(); | |
| setInterval(loadStats, 5000); | |
| setInterval(loadInbounds, 15000); | |
| </script> | |
| </body> | |
| </html>""" | |
| 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) | |
| 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 | |
| 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") | |
| 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") | |
| import requests | |
| requests.get(url="https://www.duckdns.org/update?domains=chikichiki&token=60d3e998-7fe7-4314-9a5b-c8e8811bb730&verbose=true") | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=CONFIG["port"]) |