Files
Nexus/server/infrastructure/subscription_parser.py
T

122 lines
3.9 KiB
Python
Raw Normal View History

"""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