diff --git a/aggregator.py b/aggregator.py index 31a653e..b2c0854 100644 --- a/aggregator.py +++ b/aggregator.py @@ -62,7 +62,7 @@ _background_tasks: List[asyncio.Task] = [] _links_cache: Dict[str, Tuple[List[str], float]] = {} _traffic_cache: Dict[str, Tuple[Dict, float]] = {} CACHE_TTL_TRAFFIC = 120 -CACHE_TTL_LINKS = 86400 * 7 +CACHE_TTL_LINKS = 3600 # Server health tracking _server_health: Dict[str, bool] = {} @@ -499,6 +499,16 @@ def get_traffic_stats(sub_id: str) -> dict: def generate_sub_id(length: int = 16) -> str: return ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(length)) +def _link_protocol(link: str) -> str: + if link.startswith("hy2://") or link.startswith("hysteria2://"): + return "hysteria" + if link.startswith("trojan://"): + return "trojan" + return "vless" + +def _get_inbound_protocol(inbound: dict) -> str: + return inbound.get("protocol", "vless") + async def fetch_vless_links(url: str) -> List[str]: try: async with httpx.AsyncClient(verify=False, timeout=10.0, follow_redirects=True) as client: @@ -511,12 +521,12 @@ async def fetch_vless_links(url: str) -> List[str]: content = resp.text.strip() try: decoded = base64.b64decode(content).decode('utf-8') - links = re.findall(r'([a-z]+://[^\s\n]+)', decoded) + links = re.findall(r'([a-z][a-z0-9]*://[^\s\n]+)', decoded) if links: return links except: pass - return re.findall(r'([a-z]+://[^\s\n]+)', content) + return re.findall(r'([a-z][a-z0-9]*://[^\s\n]+)', content) except Exception as e: logger.warning(f"Server unreachable {url}: {e}") return [] @@ -650,6 +660,8 @@ async def lifespan(app): if not is_server_alive(srv.get("name", "")): continue for inbound in srv.get("inbounds", []): + if inbound.get("protocol") in ("hysteria",): + continue api_host = inbound.get("api_host") api_user = inbound.get("api_user") api_pass = inbound.get("api_pass") @@ -913,16 +925,19 @@ async def get_subscription(request: Request, subscription_id: str, format: str = if not links: continue - srv_inbounds = [ib for s, ib in inbounds if s["name"] == srv_name] - try: - link_idx = srv_inbounds.index(inbound) - except ValueError: + ib_protocol = _get_inbound_protocol(inbound) + matched_link = None + for candidate in links: + if _link_protocol(candidate) == ib_protocol: + clean_candidate = candidate.split('#')[0] + if clean_candidate not in seen_links: + matched_link = candidate + break + + if not matched_link: continue - if link_idx >= len(links): - continue - - link = links[link_idx] + link = matched_link clean_link = link.split('#')[0] if clean_link in seen_links: continue @@ -1369,6 +1384,46 @@ async def tg_webhook(): return JSONResponse({"status": "ok", "bot": True, "message": "Telegram bot is ready"}) return JSONResponse({"status": "ok", "bot": False, "message": "Telegram bot not configured"}) +def _hysteria_api_call(api_host: str, api_user: str, api_pass: str, endpoint: str, data: dict = None) -> dict: + try: + import urllib.parse + base = api_host.rstrip("/") + + with httpx.Client(verify=False, timeout=15.0) as c: + login_resp = c.post(f"{base}/login", data={"username": api_user, "password": api_pass}) + cookie = login_resp.cookies.get("3x-ui", "") + + headers = {"Cookie": f"3x-ui={cookie}", "Content-Type": "application/x-www-form-urlencoded"} + url = f"{base}/{endpoint.lstrip('/')}" + if data: + resp = c.post(url, data=urllib.parse.urlencode(data), headers=headers) + else: + resp = c.get(url, headers=headers) + return resp.json() + except Exception as e: + return {"success": False, "msg": str(e)[:100]} + +def _hysteria_get_inbound(api_host: str, api_user: str, api_pass: str, inbound_id: int) -> dict: + result = _hysteria_api_call(api_host, api_user, api_pass, "panel/api/inbounds/list") + for ib in result.get("obj", []): + if ib.get("id") == inbound_id: + return ib + return {} + +def _hysteria_update_inbound(api_host: str, api_user: str, api_pass: str, inbound_id: int, settings_str: str, raw_obj: dict) -> dict: + return _hysteria_api_call(api_host, api_user, api_pass, f"panel/api/inbounds/update/{inbound_id}", { + "id": inbound_id, + "remark": raw_obj.get("remark", ""), + "enable": "true" if raw_obj.get("enable") else "false", + "listen": raw_obj.get("listen", ""), + "port": raw_obj.get("port", ""), + "protocol": raw_obj.get("protocol", "hysteria"), + "settings": settings_str, + "stream_settings": raw_obj.get("stream_settings", "{}"), + "sniffing": raw_obj.get("sniffing", "{}"), + "tag": raw_obj.get("tag", "") + }) + def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: int = 0) -> dict: api_host = inbound.get("api_host") api_user = inbound.get("api_user") @@ -1379,17 +1434,58 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in return {"success": False, "error": "missing_credentials"} try: - api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False) - api.login() - inbound_name = inbound.get('name', 'default') email = f"{username}_{inbound_name}@vless.local" total_bytes = traffic_gb * 1073741824 if traffic_gb > 0 else 0 logger.info(f"Creating client: email={email}, total_bytes={total_bytes}, inbound={inbound_name}") + protocol = inbound.get("protocol", "vless") + + if protocol == "hysteria": + ib_data = _hysteria_get_inbound(api_host, api_user, api_pass, inbound_id) + settings = json.loads(ib_data.get("settings", "{}")) + existing_clients = settings.get("clients", []) + + new_clients = [c for c in existing_clients if c.get("email") != email] + client_password = secrets.token_urlsafe(16) + new_clients.append({ + "auth": client_password, + "email": email, + "enable": True, + "limitIp": 0, + "totalGB": total_bytes, + "expiryTime": 0, + "subId": sub_id, + "comment": "", + "reset": 0 + }) + + settings["clients"] = new_clients + settings["version"] = 2 + + result = _hysteria_update_inbound(api_host, api_user, api_pass, inbound_id, json.dumps(settings), ib_data) + if result.get("success"): + logger.info(f"Client created successfully on {inbound_name} (hysteria)") + return {"success": True, "email": email} + else: + error_msg = result.get("msg", "unknown error") + logger.error(f"Hysteria client creation failed: {error_msg}") + return {"success": False, "error": error_msg[:150]} + + api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False) + api.login() + + try: + existing = api.client.get_by_email(email) + if existing: + logger.info(f"Deleting existing client: {existing.id}") + api.client.delete(inbound_id, existing.id) + except: + pass + from py3xui.client import Client - if inbound.get("protocol") == "trojan": + if protocol == "trojan": client = Client( id="", password=str(uuid.uuid4()), @@ -1411,14 +1507,6 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in subId=sub_id ) - try: - existing = api.client.get_by_email(email) - if existing: - logger.info(f"Deleting existing client: {existing.id}") - api.client.delete(inbound_id, existing.id) - except: - pass - logger.info(f"About to add client: {client}") api.client.add(inbound_id=inbound_id, clients=[client]) logger.info(f"Client created successfully on {inbound_name}") @@ -1440,6 +1528,27 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict: return {"success": False, "error": "missing_credentials"} try: + inbound_name = inbound.get('name', 'default') + protocol = inbound.get("protocol", "vless") + + if protocol == "hysteria": + ib_data = _hysteria_get_inbound(api_host, api_user, api_pass, inbound_id) + settings = json.loads(ib_data.get("settings", "{}")) + existing_clients = settings.get("clients", []) + + new_clients = [c for c in existing_clients if c.get("subId") != sub_id] + if len(new_clients) == len(existing_clients): + return {"success": True, "error": "not_found"} + + settings["clients"] = new_clients + settings["version"] = 2 + + result = _hysteria_update_inbound(api_host, api_user, api_pass, inbound_id, json.dumps(settings), ib_data) + if result.get("success"): + logger.info(f"Deleted client from {inbound_name} (hysteria)") + return {"success": True} + return {"success": False, "error": result.get("msg", "unknown")[:100]} + api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False) api.login() @@ -1448,8 +1557,8 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict: if ib.id == inbound_id and ib.client_stats: for client in ib.client_stats: if getattr(client, 'sub_id', '') == sub_id: - api.client.delete(inbound_id, client.uuid) - logger.info(f"Deleted client from {inbound.get('name')}") + api.client.delete(inbound_id, client.uuid or str(client.id)) + logger.info(f"Deleted client from {inbound_name}") return {"success": True} return {"success": True, "error": "not_found"} @@ -1733,6 +1842,8 @@ async def rotate_shortids(): if not is_server_alive(srv.get("name", "")): continue for inbound in srv.get("inbounds", []): + if inbound.get("protocol") in ("hysteria",): + continue api_host = inbound.get("api_host") api_user = inbound.get("api_user") api_pass = inbound.get("api_pass") diff --git a/hashes.txt b/hashes.txt new file mode 100644 index 0000000..94c5fce --- /dev/null +++ b/hashes.txt @@ -0,0 +1,7 @@ +# VK Call hashes (one per line) +# Format: https://vk.com/call/join/ or just the hash itself +# Minimum hash length: 16 characters +https://vk.com/call/join/db7b2b2b5c4d8e9f0a1b2c3d4e5f6a7b +https://vk.com/call/join/7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d +https://vk.com/call/join/f1e2d3c4b5a69788796a5b4c3d2e1f0a +https://vk.com/call/join/abc123def456ghi789jkl012mno345pqr diff --git a/web/css/home.css b/web/css/home.css index dd5c239..5df675c 100644 --- a/web/css/home.css +++ b/web/css/home.css @@ -142,6 +142,36 @@ body::before { } .btn:hover { transform:translateY(-2px); box-shadow:0 8px 32px rgba(108,99,255,0.2) } .btn.da { background:linear-gradient(135deg,#f43f5e,#e11d48); color:#fff } +.btn.tg { + position:relative; + background:linear-gradient(135deg,#1e96c8,#117a9e); + color:#fff; + overflow:hidden; + isolation:isolate; +} +.btn.tg::before { + content:''; + position:absolute;inset:0; + background:linear-gradient(135deg,rgba(255,255,255,0.18) 0%,transparent 50%,rgba(255,255,255,0.06) 100%); + opacity:0; + transition:opacity .4s; +} +.btn.tg::after { + content:''; + position:absolute;inset:-2px; + border-radius:14px; + background:linear-gradient(135deg,rgba(30,150,200,0.4),rgba(108,99,255,0.25)); + z-index:-1; + filter:blur(12px); + opacity:0; + transition:opacity .4s; +} +.btn.tg:hover::before { opacity:1 } +.btn.tg:hover::after { opacity:1 } +.btn.tg:hover { + transform:translateY(-3px); + box-shadow:0 8px 32px rgba(30,150,200,0.35),0 0 0 1px rgba(30,150,200,0.3); +} .footer { text-align:center; margin-top:40px; color:var(--text-ter); font-size:12px; animation:fadeUp .6s .6s ease-out both } diff --git a/web/home.html b/web/home.html index ef40928..abb2e1b 100644 --- a/web/home.html +++ b/web/home.html @@ -41,6 +41,7 @@
❤️ Поддержать проект +✈️ Связаться