fix: protocol-based link matching, hysteria panel support, links cache TTL 1h

- match subscription links by protocol instead of index (fixes trojan/mixed inbounds, e.g. Poland)
- support hysteria panels in create/delete client, skip in shortid rotation
- fix link regex to match hysteria2:// scheme
- reduce links cache TTL from 7 days to 1 hour (stale cache hid servers)
- delete client: fallback to id when uuid missing
- feat(web): telegram contact button on home page
This commit is contained in:
SashegDev
2026-09-10 05:26:08 +00:00
parent 255caeb069
commit 7ead9d19ce
4 changed files with 174 additions and 25 deletions
+136 -25
View File
@@ -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")