feat: working hysteria2 via standalone server with enriched links

- add _build_hysteria_link: rebuild bare panel hy2 links into
  hysteria2://sub_id:auth@host:port?obfs=salamander&obfs-password=..\&sni=..
  using per-server hy2 block in servers.conf (gitignored)
- add _sync_hysteria_users hook: push panel hy2 clients to the
  standalone server config on create/delete (cron backstop every 2 min)
This commit is contained in:
SashegDev
2026-09-10 06:16:02 +00:00
parent 7ead9d19ce
commit 64b2fa8789
+47
View File
@@ -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]}