32feb1b6db
Fixes: - F821: install.py site_url 未定义(NameError)、sync_v2.py os 未导入、script_jobs.py LOG f-string - F401: 移除 22 个未使用导入(models/__init__.py、install.py、auth.py 等) - B904: 20 个 except 块 raise 添加 from e/from None - S110: 20 个有意的 try-except-pass 添加 noqa 注释(SSH清理/DDL幂等/WS断连) - B007: 3 个未使用循环变量重命名(dirs→_dirs、server_id→_server_id) - F541: 3 个无占位符 f-string 修正 - F841: auth.py 未使用 ip_address 变量移除 - S105: auth_service.py Redis key prefix 误报 noqa - B023: script_execution_flush.py 循环变量绑定修复 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
3.9 KiB
Python
122 lines
3.9 KiB
Python
"""Parse proxy subscription URLs and extract node IP addresses.
|
|
|
|
Supports: SS (ShadowSocks), VMess, VLESS, Trojan, Hysteria2.
|
|
Used for building login IP allowlists from proxy service subscriptions.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger("nexus.subscription_parser")
|
|
|
|
# Simple IPv4 pattern
|
|
_IPV4_RE = re.compile(r"^(\d{1,3}\.){3}\d{1,3}$")
|
|
# Simple IPv6 — we accept the host field as-is if not IPv4/hostname
|
|
_DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$")
|
|
|
|
|
|
def _decode_b64(s: str) -> str:
|
|
"""Best-effort Base64 decode with padding fix."""
|
|
s = s.strip().rstrip("=")
|
|
padding = 4 - len(s) % 4 if len(s) % 4 else 0
|
|
return base64.b64decode(s + "=" * padding, altchars=b"-_").decode("utf-8", errors="ignore")
|
|
|
|
|
|
def _extract_host_from_url(raw: str) -> Optional[str]:
|
|
"""Parse a URL and return the hostname."""
|
|
try:
|
|
p = urlparse(raw)
|
|
return p.hostname or None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _parse_ss(line: str) -> Optional[str]:
|
|
"""Parse ShadowSocks URI: ss://BASE64#tag or ss://BASE64@host:port#tag"""
|
|
body = line[5:].split("#")[0] # strip prefix and fragment
|
|
if "@" in body:
|
|
# New format: ss://METHOD:PWD@HOST:PORT
|
|
return _extract_host_from_url(line)
|
|
# Old format: ss://BASE64(method:pwd@host:port)
|
|
try:
|
|
decoded = _decode_b64(body)
|
|
if "@" in decoded:
|
|
host_part = decoded.split("@", 1)[1].split(":")[0]
|
|
return host_part
|
|
except Exception: # noqa: S110 — non-critical URL parse fallback
|
|
pass
|
|
return None
|
|
|
|
|
|
def _parse_vmess(line: str) -> Optional[str]:
|
|
"""Parse VMess URI: vmess://BASE64(JSON) — JSON has 'add' key for host."""
|
|
try:
|
|
body = line[8:]
|
|
json_str = _decode_b64(body)
|
|
data = json.loads(json_str)
|
|
return data.get("add") or data.get("host") or None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _parse_simple_url(line: str) -> Optional[str]:
|
|
"""Schemes where host is directly in URL: vless, trojan, hysteria2, hy2, tuic."""
|
|
return _extract_host_from_url(line)
|
|
|
|
|
|
def _is_valid_host(host: Optional[str]) -> bool:
|
|
if not host:
|
|
return False
|
|
host = host.strip()
|
|
if not host or host in ("localhost", "127.0.0.1", "::1"):
|
|
return False
|
|
if _IPV4_RE.match(host):
|
|
return True
|
|
if _DOMAIN_RE.match(host):
|
|
return True
|
|
# IPv6 literal (bracketed already stripped by urlparse)
|
|
if ":" in host:
|
|
return True
|
|
return False
|
|
|
|
|
|
def parse_subscription_content(raw_content: str) -> list[str]:
|
|
"""Given subscription raw text (or base64 of it), return unique host/IP list."""
|
|
# Try to decode as base64 first (many subscriptions are base64-encoded)
|
|
content = raw_content.strip()
|
|
if "\n" not in content and len(content) > 50:
|
|
try:
|
|
decoded = _decode_b64(content)
|
|
if any(decoded.startswith(p) for p in ("ss://", "vmess://", "vless://", "trojan://")):
|
|
content = decoded
|
|
except Exception: # noqa: S110 — non-critical parse, treat as non-encoded
|
|
pass
|
|
|
|
hosts: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
for line in content.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
host: Optional[str] = None
|
|
|
|
if line.startswith("ss://"):
|
|
host = _parse_ss(line)
|
|
elif line.startswith("vmess://"):
|
|
host = _parse_vmess(line)
|
|
elif line.startswith(("vless://", "trojan://", "hysteria2://", "hy2://", "tuic://")):
|
|
host = _parse_simple_url(line)
|
|
|
|
if host and _is_valid_host(host) and host not in seen:
|
|
seen.add(host)
|
|
hosts.append(host)
|
|
|
|
logger.info("Subscription parsed: %d nodes → %d unique hosts", len(content.splitlines()), len(hosts))
|
|
return hosts
|