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:
@@ -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]}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user