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]] = {}
|
_links_cache: Dict[str, Tuple[List[str], float]] = {}
|
||||||
_traffic_cache: Dict[str, Tuple[Dict, float]] = {}
|
_traffic_cache: Dict[str, Tuple[Dict, float]] = {}
|
||||||
CACHE_TTL_TRAFFIC = 120
|
CACHE_TTL_TRAFFIC = 120
|
||||||
CACHE_TTL_LINKS = 86400 * 7
|
CACHE_TTL_LINKS = 3600
|
||||||
|
|
||||||
# Server health tracking
|
# Server health tracking
|
||||||
_server_health: Dict[str, bool] = {}
|
_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:
|
def generate_sub_id(length: int = 16) -> str:
|
||||||
return ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(length))
|
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]:
|
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:
|
||||||
@@ -511,12 +521,12 @@ async def fetch_vless_links(url: str) -> List[str]:
|
|||||||
content = resp.text.strip()
|
content = resp.text.strip()
|
||||||
try:
|
try:
|
||||||
decoded = base64.b64decode(content).decode('utf-8')
|
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:
|
if links:
|
||||||
return links
|
return links
|
||||||
except:
|
except:
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Server unreachable {url}: {e}")
|
logger.warning(f"Server unreachable {url}: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -650,6 +660,8 @@ async def lifespan(app):
|
|||||||
if not is_server_alive(srv.get("name", "")):
|
if not is_server_alive(srv.get("name", "")):
|
||||||
continue
|
continue
|
||||||
for inbound in srv.get("inbounds", []):
|
for inbound in srv.get("inbounds", []):
|
||||||
|
if inbound.get("protocol") in ("hysteria",):
|
||||||
|
continue
|
||||||
api_host = inbound.get("api_host")
|
api_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
api_pass = inbound.get("api_pass")
|
||||||
@@ -913,16 +925,19 @@ async def get_subscription(request: Request, subscription_id: str, format: str =
|
|||||||
if not links:
|
if not links:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
srv_inbounds = [ib for s, ib in inbounds if s["name"] == srv_name]
|
ib_protocol = _get_inbound_protocol(inbound)
|
||||||
try:
|
matched_link = None
|
||||||
link_idx = srv_inbounds.index(inbound)
|
for candidate in links:
|
||||||
except ValueError:
|
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
|
continue
|
||||||
|
|
||||||
if link_idx >= len(links):
|
link = matched_link
|
||||||
continue
|
|
||||||
|
|
||||||
link = links[link_idx]
|
|
||||||
clean_link = link.split('#')[0]
|
clean_link = link.split('#')[0]
|
||||||
if clean_link in seen_links:
|
if clean_link in seen_links:
|
||||||
continue
|
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": True, "message": "Telegram bot is ready"})
|
||||||
return JSONResponse({"status": "ok", "bot": False, "message": "Telegram bot not configured"})
|
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:
|
def create_3xui_client(username: str, sub_id: str, inbound: dict, traffic_gb: int = 0) -> dict:
|
||||||
api_host = inbound.get("api_host")
|
api_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
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"}
|
return {"success": False, "error": "missing_credentials"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
|
|
||||||
api.login()
|
|
||||||
|
|
||||||
inbound_name = inbound.get('name', 'default')
|
inbound_name = inbound.get('name', 'default')
|
||||||
email = f"{username}_{inbound_name}@vless.local"
|
email = f"{username}_{inbound_name}@vless.local"
|
||||||
total_bytes = traffic_gb * 1073741824 if traffic_gb > 0 else 0
|
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}")
|
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
|
from py3xui.client import Client
|
||||||
if inbound.get("protocol") == "trojan":
|
if protocol == "trojan":
|
||||||
client = Client(
|
client = Client(
|
||||||
id="",
|
id="",
|
||||||
password=str(uuid.uuid4()),
|
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
|
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}")
|
logger.info(f"About to add client: {client}")
|
||||||
api.client.add(inbound_id=inbound_id, clients=[client])
|
api.client.add(inbound_id=inbound_id, clients=[client])
|
||||||
logger.info(f"Client created successfully on {inbound_name}")
|
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"}
|
return {"success": False, "error": "missing_credentials"}
|
||||||
|
|
||||||
try:
|
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 = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
|
||||||
api.login()
|
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:
|
if ib.id == inbound_id and ib.client_stats:
|
||||||
for client in ib.client_stats:
|
for client in ib.client_stats:
|
||||||
if getattr(client, 'sub_id', '') == sub_id:
|
if getattr(client, 'sub_id', '') == sub_id:
|
||||||
api.client.delete(inbound_id, client.uuid)
|
api.client.delete(inbound_id, client.uuid or str(client.id))
|
||||||
logger.info(f"Deleted client from {inbound.get('name')}")
|
logger.info(f"Deleted client from {inbound_name}")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
return {"success": True, "error": "not_found"}
|
return {"success": True, "error": "not_found"}
|
||||||
@@ -1733,6 +1842,8 @@ async def rotate_shortids():
|
|||||||
if not is_server_alive(srv.get("name", "")):
|
if not is_server_alive(srv.get("name", "")):
|
||||||
continue
|
continue
|
||||||
for inbound in srv.get("inbounds", []):
|
for inbound in srv.get("inbounds", []):
|
||||||
|
if inbound.get("protocol") in ("hysteria",):
|
||||||
|
continue
|
||||||
api_host = inbound.get("api_host")
|
api_host = inbound.get("api_host")
|
||||||
api_user = inbound.get("api_user")
|
api_user = inbound.get("api_user")
|
||||||
api_pass = inbound.get("api_pass")
|
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: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.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 }
|
.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>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<a href="{%da_url%}" class="btn da">❤️ Поддержать проект</a>
|
<a href="{%da_url%}" class="btn da">❤️ Поддержать проект</a>
|
||||||
|
<a href="https://t.me/sashegdev" class="btn tg" target="_blank" rel="noopener">✈️ Связаться</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user