diff --git a/aggregator.py b/aggregator.py index b2c0854..c79d11a 100644 --- a/aggregator.py +++ b/aggregator.py @@ -29,6 +29,7 @@ from fastapi.staticfiles import StaticFiles import uvicorn import httpx import re +import urllib.parse from py3xui import Api import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -509,6 +510,39 @@ def _link_protocol(link: str) -> str: def _get_inbound_protocol(inbound: dict) -> str: return inbound.get("protocol", "vless") +def _build_hysteria_link(panel_link: str, sub_id: str, srv: dict) -> Optional[str]: + """Rebuild panel hysteria2://AUTH@host:port link into a working client link. + + Standalone hysteria2 server uses userpass auth (username=sub_id) plus + salamander obfs, so the bare panel link is extended to: + hysteria2://sub_id:auth@host:port?obfs=salamander&obfs-password=..&sni=.. + Server-side params come from the optional "hy2" block in servers.conf: + {"sni": "example.com", "obfs_password": "..."}. + Returns the original link when no hy2 block is configured, None on parse error. + """ + hy2 = srv.get("hy2") or {} + if not hy2.get("obfs_password"): + return panel_link + try: + parts = urllib.parse.urlparse(panel_link.split("#")[0]) + auth = urllib.parse.unquote(parts.username or "") + if not auth: + return None + host = parts.hostname or "" + port = parts.port or 443 + if not host: + return None + sni = hy2.get("sni") or host + query = urllib.parse.urlencode({ + "obfs": "salamander", + "obfs-password": hy2["obfs_password"], + "sni": sni, + }) + userinfo = f"{urllib.parse.quote(sub_id, safe='')}:{urllib.parse.quote(auth, safe='')}" + return f"hysteria2://{userinfo}@{host}:{port}?{query}" + except Exception: + return None + async def fetch_vless_links(url: str) -> List[str]: try: async with httpx.AsyncClient(verify=False, timeout=10.0, follow_redirects=True) as client: @@ -938,6 +972,8 @@ async def get_subscription(request: Request, subscription_id: str, format: str = continue link = matched_link + if ib_protocol == "hysteria": + link = _build_hysteria_link(link, subscription_id, srv) or link clean_link = link.split('#')[0] if clean_link in seen_links: continue @@ -1403,6 +1439,15 @@ def _hysteria_api_call(api_host: str, api_user: str, api_pass: str, endpoint: st except Exception as e: return {"success": False, "msg": str(e)[:100]} +def _sync_hysteria_users(): + """Push panel hysteria clients into standalone hysteria2 server config (best-effort).""" + try: + import subprocess + subprocess.run(["/usr/bin/python3", "/opt/hysteria/sync_users.py"], + capture_output=True, timeout=60) + except Exception as e: + logger.warning(f"hysteria users sync failed: {e}") + 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", []): @@ -1467,6 +1512,7 @@ def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: in 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)") + _sync_hysteria_users() return {"success": True, "email": email} else: error_msg = result.get("msg", "unknown error") @@ -1546,6 +1592,7 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict: 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)") + _sync_hysteria_users() return {"success": True} return {"success": False, "error": result.get("msg", "unknown")[:100]}