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 uvicorn
import httpx import httpx
import re import re
import urllib.parse
from py3xui import Api from py3xui import Api
import urllib3 import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -509,6 +510,39 @@ def _link_protocol(link: str) -> str:
def _get_inbound_protocol(inbound: dict) -> str: def _get_inbound_protocol(inbound: dict) -> str:
return inbound.get("protocol", "vless") 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]: async def fetch_vless_links(url: str) -> List[str]:
try: try:
async with httpx.AsyncClient(verify=False, timeout=10.0, follow_redirects=True) as client: 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 continue
link = matched_link link = matched_link
if ib_protocol == "hysteria":
link = _build_hysteria_link(link, subscription_id, srv) or link
clean_link = link.split('#')[0] clean_link = link.split('#')[0]
if clean_link in seen_links: if clean_link in seen_links:
continue continue
@@ -1403,6 +1439,15 @@ def _hysteria_api_call(api_host: str, api_user: str, api_pass: str, endpoint: st
except Exception as e: except Exception as e:
return {"success": False, "msg": str(e)[:100]} 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: 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") result = _hysteria_api_call(api_host, api_user, api_pass, "panel/api/inbounds/list")
for ib in result.get("obj", []): 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) result = _hysteria_update_inbound(api_host, api_user, api_pass, inbound_id, json.dumps(settings), ib_data)
if result.get("success"): if result.get("success"):
logger.info(f"Client created successfully on {inbound_name} (hysteria)") logger.info(f"Client created successfully on {inbound_name} (hysteria)")
_sync_hysteria_users()
return {"success": True, "email": email} return {"success": True, "email": email}
else: else:
error_msg = result.get("msg", "unknown error") 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) result = _hysteria_update_inbound(api_host, api_user, api_pass, inbound_id, json.dumps(settings), ib_data)
if result.get("success"): if result.get("success"):
logger.info(f"Deleted client from {inbound_name} (hysteria)") logger.info(f"Deleted client from {inbound_name} (hysteria)")
_sync_hysteria_users()
return {"success": True} return {"success": True}
return {"success": False, "error": result.get("msg", "unknown")[:100]} return {"success": False, "error": result.get("msg", "unknown")[:100]}