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:
+136
-25
@@ -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")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# VK Call hashes (one per line)
|
||||
# Format: https://vk.com/call/join/<hash> or just the hash itself
|
||||
# Minimum hash length: 16 characters
|
||||
https://vk.com/call/join/db7b2b2b5c4d8e9f0a1b2c3d4e5f6a7b
|
||||
https://vk.com/call/join/7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d
|
||||
https://vk.com/call/join/f1e2d3c4b5a69788796a5b4c3d2e1f0a
|
||||
https://vk.com/call/join/abc123def456ghi789jkl012mno345pqr
|
||||
@@ -142,6 +142,36 @@ body::before {
|
||||
}
|
||||
.btn:hover { transform:translateY(-2px); box-shadow:0 8px 32px rgba(108,99,255,0.2) }
|
||||
.btn.da { background:linear-gradient(135deg,#f43f5e,#e11d48); color:#fff }
|
||||
.btn.tg {
|
||||
position:relative;
|
||||
background:linear-gradient(135deg,#1e96c8,#117a9e);
|
||||
color:#fff;
|
||||
overflow:hidden;
|
||||
isolation:isolate;
|
||||
}
|
||||
.btn.tg::before {
|
||||
content:'';
|
||||
position:absolute;inset:0;
|
||||
background:linear-gradient(135deg,rgba(255,255,255,0.18) 0%,transparent 50%,rgba(255,255,255,0.06) 100%);
|
||||
opacity:0;
|
||||
transition:opacity .4s;
|
||||
}
|
||||
.btn.tg::after {
|
||||
content:'';
|
||||
position:absolute;inset:-2px;
|
||||
border-radius:14px;
|
||||
background:linear-gradient(135deg,rgba(30,150,200,0.4),rgba(108,99,255,0.25));
|
||||
z-index:-1;
|
||||
filter:blur(12px);
|
||||
opacity:0;
|
||||
transition:opacity .4s;
|
||||
}
|
||||
.btn.tg:hover::before { opacity:1 }
|
||||
.btn.tg:hover::after { opacity:1 }
|
||||
.btn.tg:hover {
|
||||
transform:translateY(-3px);
|
||||
box-shadow:0 8px 32px rgba(30,150,200,0.35),0 0 0 1px rgba(30,150,200,0.3);
|
||||
}
|
||||
|
||||
.footer { text-align:center; margin-top:40px; color:var(--text-ter); font-size:12px; animation:fadeUp .6s .6s ease-out both }
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
</div>
|
||||
<div class="btns">
|
||||
<a href="{%da_url%}" class="btn da">❤️ Поддержать проект</a>
|
||||
<a href="https://t.me/sashegdev" class="btn tg" target="_blank" rel="noopener">✈️ Связаться</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user