refactor: split monolith into typed modules with retry logic

- models.py: ProxyInfo and DNSServerConfig dataclasses with type hints
- fetcher.py: subscription fetcher with exponential backoff retries (3 attempts) plus VLESS URL parser
- config_builder.py: sing-box config builder decomposed into focused helpers
- main.py: orchestrator with setup_logging(), print_banner(), pick_node()
This commit is contained in:
2026-07-09 08:14:23 +00:00
parent d74ea6cc69
commit ed52257dbe
4 changed files with 566 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Sing-box configuration builder."""
import logging
from typing import Any, Dict, List, Optional
from models import ProxyInfo
logger = logging.getLogger(__name__)
# Default SNI mapping for DNS-over-TLS with IP addresses
SNI_MAP: Dict[str, str] = {
"8.8.8.8": "dns.google",
"8.8.4.4": "dns.google",
"1.1.1.1": "cloudflare-dns.com",
"1.0.0.1": "cloudflare-dns.com",
}
def make_dns_server(addr_str: str) -> Dict[str, Any]:
"""Parse common DNS address strings into native sing-box v1.13+ server dict.
Supports: udp://host:port, tls://host(:port), https://host(:port), bare IP/hostname.
Args:
addr_str: DNS server address string (e.g. "tls://8.8.8.8", "udp://1.1.1.1:53").
Returns:
Dictionary compatible with sing-box DNS server schema.
"""
import urllib.parse as up
stripped = addr_str.strip()
parts = up.urlparse(stripped)
scheme = parts.scheme.lower() if parts.scheme else ""
host = parts.hostname or stripped
port = parts.port or 53
config: Dict[str, Any] = {
"type": scheme or "udp",
"server": host,
"server_port": port,
}
if scheme == "tls":
config["tls"] = {"server_name": SNI_MAP.get(host, host)}
elif scheme == "https":
config["server_port"] = port or 443
config["tls"] = {"server_name": host}
return config
def build_singbox_config(
proxy: ProxyInfo,
socks_port: int = 1080,
socks_user: str = "",
socks_pass: str = "",
dns_server: str = "tls://8.8.8.8",
log_level: str = "info",
) -> Dict[str, Any]:
"""Build a complete sing-box JSON configuration.
Args:
proxy: Parsed VLESS proxy information.
socks_port: Local SOCKS5 listen port.
socks_user: Optional username for SOCKS auth (empty disables auth).
socks_pass: Optional password for SOCKS auth.
dns_server: Upstream DNS resolver address string.
log_level: Sing-box log level.
Returns:
Dictionary representing the full sing-box configuration.
"""
outbound = _build_proxy_outbound(proxy)
inbound = _build_socks_inbound(
port=socks_port,
username=socks_user,
password=socks_pass,
)
dns = _build_dns_config(dns_server)
config: Dict[str, Any] = {
"log": {"level": log_level},
"dns": dns,
"inbounds": [inbound],
"outbounds": [
outbound,
{"type": "direct", "tag": "direct"},
],
"route": {
"default_domain_resolver": "remote",
"rules": [{
"clash_mode": "direct",
"outbound": "direct",
}],
"final": "proxy",
},
}
logger.debug("Built sing-box config with outbound tag 'proxy'")
return config
def _build_proxy_outbound(proxy: ProxyInfo) -> Dict[str, Any]:
"""Build the VLESS outbound proxy object."""
outbound: Dict[str, Any] = {
"type": "vless",
"tag": "proxy",
"server": proxy.server,
"server_port": proxy.port,
"uuid": proxy.uuid,
"packet_encoding": "xudp",
}
security = proxy.security or "tls"
if security == "reality":
outbound["tls"] = {
"enabled": True,
"server_name": proxy.sni,
"utls": {"enabled": True, "fingerprint": "chrome"},
"reality": {
"enabled": True,
"public_key": proxy.pbk,
"short_id": proxy.sid or proxy.spx,
},
}
elif security == "tls":
tls_config: Dict[str, Any] = {
"enabled": True,
"server_name": proxy.sni,
"utls": {"enabled": True, "fingerprint": "chrome"},
}
if proxy.alpn:
tls_config["alpn"] = [a.strip() for a in proxy.alpn.split(",") if a.strip()]
outbound["tls"] = tls_config
transport = _build_transport(proxy)
if transport:
outbound["transport"] = transport
return outbound
def _build_transport(proxy: ProxyInfo) -> Optional[Dict[str, Any]]:
"""Build the transport configuration (only for non-TCP types)."""
transport_type = proxy.type or "tcp"
transport: Dict[str, Any] = {}
if transport_type == "ws":
ws: Dict[str, Any] = {"type": "ws", "path": proxy.path or "/"}
if proxy.host:
ws["headers"] = {"Host": proxy.host}
return ws
elif transport_type == "http":
http_cfg: Dict[str, Any] = {"type": "http"}
if proxy.host:
http_cfg["host"] = [h.strip() for h in proxy.host.split(",") if h.strip()]
http_cfg["path"] = proxy.path or "/"
return http_cfg
elif transport_type == "grpc":
grpc: Dict[str, Any] = {"type": "grpc"}
svc_name = proxy.serviceName or proxy.path
if svc_name:
grpc["service_name"] = svc_name
return grpc
elif transport_type == "httpupgrade":
hu: Dict[str, Any] = {"type": "httpupgrade", "path": proxy.path or "/"}
if proxy.host:
hu["headers"] = {"Host": proxy.host}
return hu
return None
def _build_socks_inbound(
port: int,
username: str = "",
password: str = "",
) -> Dict[str, Any]:
"""Build the SOCKS5 inbound object."""
inbound: Dict[str, Any] = {
"type": "socks",
"listen": "::",
"listen_port": port,
}
if username:
inbound["users"] = [
{"username": username, "password": password},
]
return inbound
def _build_dns_config(dns_server_addr: str) -> Dict[str, Any]:
"""Build the DNS section of the config."""
dns_server_obj = make_dns_server(dns_server_addr)
return {
"servers": [
{**dns_server_obj, "tag": "remote"},
{"type": "local", "tag": "local"},
],
"final": "remote",
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Subscription fetching and VLESS URL parsing."""
import base64
import logging
import time
import urllib.error
import urllib.parse as up
import urllib.request
from typing import Dict, List, Optional
from models import ProxyInfo
logger = logging.getLogger(__name__)
def fetch_subscription(url: str, max_retries: int = 3, backoff_base: float = 1.0) -> str:
"""Download subscription content with retry and exponential backoff.
Args:
url: Subscription URL to download.
max_retries: Maximum number of retry attempts (default 3).
backoff_base: Base delay in seconds for exponential backoff (default 1.0s -> 1, 2, 4).
Returns:
Decoded subscription text content.
Raises:
urllib.error.URLError: If all retries fail.
Exception: On non-recoverable errors.
"""
last_error: Optional[Exception] = None
for attempt in range(max_retries):
try:
logger.info("Fetching subscription from %s (attempt %d/%d)", url, attempt + 1, max_retries)
req = urllib.request.Request(url, headers={"User-Agent": "clash.meta"})
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
logger.debug("Raw data is base64 encoded, decoding...")
content = base64.b64decode(raw).decode("utf-8")
if attempt > 0:
logger.info("Fetch succeeded on attempt %d", attempt + 1)
return content
except urllib.error.URLError as e:
last_error = e
wait_time = backoff_base * (2 ** attempt)
logger.warning("Fetch failed (attempt %d/%d): %s. Retrying in %.1fs...",
attempt + 1, max_retries, e, wait_time)
if attempt < max_retries - 1:
time.sleep(wait_time)
except Exception as e:
logger.error("Non-recoverable fetch error: %s", e)
raise
logger.error("All %d fetch attempts failed for %s", max_retries, url)
raise last_error # type: ignore[misc]
def extract_vless_lines(text: str) -> List[str]:
"""Extract VLESS URI lines from subscription text.
Handles both plain and base64-encoded line formats.
Args:
text: Raw subscription content.
Returns:
List of VLESS URL strings.
"""
result: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.lower().startswith("vless://"):
result.append(stripped)
continue
try:
decoded = base64.b64decode(stripped).decode("utf-8")
if decoded.lower().startswith("vless://"):
result.append(decoded)
except Exception:
pass
return result
def parse_vless_url(url_str: str) -> Dict[str, str]:
"""Parse a VLESS URL string into a component dictionary.
Args:
url_str: VLESS subscription URI (e.g. vless://uuid@host:port?params).
Returns:
Dictionary with all parsed proxy fields.
"""
parsed = up.urlparse(url_str.strip())
query_params = up.parse_qs(parsed.query)
def get_param(key: str, default: Optional[str] = None) -> Optional[str]:
if key in query_params:
return query_params[key][0]
return default
return {
"uuid": parsed.username or "",
"server": parsed.hostname or "",
"port": parsed.port or (443 if parsed.scheme == "vless+tls" else 80),
"security": get_param("security", "tls"),
"type": get_param("type", "tcp"),
"sni": get_param("sni") or "",
"alpn": get_param("alpn") or "",
"pbk": get_param("public_key") or get_param("pbk") or "",
"sid": get_param("sid") or "",
"spx": get_param("spx") or "",
"path": get_param("path") or "/",
"serviceName": get_param("serviceName") or get_param("service-name") or "",
"host": get_param("host") or "",
"flow": get_param("flow") or "",
}
def url_to_proxy_info(url_str: str) -> ProxyInfo:
"""Convert a VLESS URL directly to a ProxyInfo dataclass.
Args:
url_str: VLESS subscription URI.
Returns:
ProxyInfo instance with parsed fields.
"""
raw = parse_vless_url(url_str)
return ProxyInfo(
uuid=raw["uuid"],
server=raw["server"],
port=int(raw["port"]),
security=raw["security"],
type=raw["type"],
sni=raw["sni"],
alpn=raw["alpn"],
pbk=raw["pbk"],
sid=raw["sid"],
spx=raw["spx"],
path=raw["path"],
serviceName=raw["serviceName"],
host=raw["host"],
flow=raw["flow"],
)
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Main orchestrator for Sub2SOCKS."""
import json
import logging
import os
import random
import sys
from typing import List, Optional
import fetcher
import config_builder
logger = logging.getLogger(__name__)
def setup_logging(level: str = "info") -> None:
"""Configure root logger with proper formatting.
Args:
level: Log level string (debug, info, warn, error).
"""
numeric_level = getattr(logging, level.upper(), logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
))
root = logging.getLogger()
root.setLevel(numeric_level)
root.addHandler(handler)
def pick_node(vless_urls: List[str], strategy: str = "first") -> Optional[str]:
"""Select a VLESS URL from the list based on the given strategy.
Args:
vless_urls: List of VLESS URL strings.
strategy: 'first' or 'random'. Defaults to 'first'.
Returns:
Selected VLESS URL string, or None if list is empty.
"""
if not vless_urls:
return None
if strategy == "random":
chosen = random.choice(vless_urls)
else:
chosen = vless_urls[0]
logger.debug("Chosen node URL (truncated): %s", chosen[:60] + "...")
return chosen
def run(
sub_url: str,
socks_port: int = 1080,
socks_user: str = "",
socks_pass: str = "",
dns_server: str = "tls://8.8.8.8",
pick_strategy: str = "first",
log_level: str = "info",
config_file_path: str = "/tmp/.sub2socks.json",
) -> None:
"""Execute the full Sub2SOCKS startup sequence and exec into sing-box.
Args:
sub_url: Subscription URL containing VLESS nodes.
socks_port: Local SOCKS5 listen port.
socks_user: Optional auth username.
socks_pass: Optional auth password.
dns_server: Upstream DNS resolver address.
pick_strategy: Node selection strategy ('first' or 'random').
log_level: Logging verbosity level.
config_file_path: Path where sing-box JSON config is written.
"""
setup_logging(log_level)
print_banner(sub_url, socks_port, dns_server, pick_strategy, log_level)
logger.info("Fetching subscription from %s", sub_url)
try:
sub_data = fetcher.fetch_subscription(sub_url)
except Exception as exc:
logger.error("Failed to reach %s: %s", sub_url, exc)
sys.exit(1)
vless_lines = fetcher.extract_vless_lines(sub_data)
if not vless_lines:
logger.error("No VLESS nodes found in subscription!")
sys.stdout.write(sub_data[:500] + "\n")
sys.exit(1)
logger.info("Found %d VLESS node(s), picking one with strategy '%s'", len(vless_lines), pick_strategy)
chosen = pick_node(vless_lines, pick_strategy)
if not chosen:
logger.error("Node selection failed.")
sys.exit(1)
proxy = fetcher.url_to_proxy_info(chosen)
logger.info("Selected node: %s:%d", proxy.server, proxy.port)
singbox_config = config_builder.build_singbox_config(
proxy=proxy,
socks_port=socks_port,
socks_user=socks_user,
socks_pass=socks_pass,
dns_server=dns_server,
log_level=log_level,
)
with open(config_file_path, "w") as fp:
json.dump(singbox_config, fp, indent=2)
logger.info("Config written to %s", config_file_path)
os.execve(
"/usr/local/bin/sing-box",
["sing-box", "run", "-c", config_file_path],
dict(os.environ),
)
def print_banner(
sub_url: str,
socks_port: int,
dns_server: str,
pick_strategy: str,
log_level: str,
) -> None:
"""Print the ASCII startup banner to stdout.
Args:
sub_url: Subscription URL being used.
socks_port: SOCKS listen port.
dns_server: DNS upstream address.
pick_strategy: Node pick strategy in use.
log_level: Active log level.
"""
banner = (
"\n"
"+-------------------------------------------------------+\n"
"| Sub2SOCKS -- bootstrapping |\n"
"+-------------------------------------------------------+\n"
f"| SUB_URL : {sub_url}\n"
f"| SOCKS port : {socks_port:>6d}\n"
f"| DNS upstream : {dns_server}\n"
f"| PICK_STRATEGY : {pick_strategy}\n"
f"| LOG_LEVEL : {log_level}\n"
"+-------------------------------------------------------+\n"
)
sys.stdout.write(banner)
def main() -> None:
"""Entry-point that reads env vars and calls run()."""
run(
sub_url=os.environ["SUB_URL"],
socks_port=int(os.environ.get("SOCKS_PORT", "1080")),
socks_user=os.environ.get("SOCKS_USER", ""),
socks_pass=os.environ.get("SOCKS_PASS", ""),
dns_server=os.environ.get("DNS_SERVER", "tls://8.8.8.8"),
pick_strategy=os.environ.get("PICK_STRATEGY", "first"),
log_level=os.environ.get("LOG_LEVEL", "info"),
)
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Data models for Sub2SOCKS."""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@dataclass
class ProxyInfo:
"""Parsed VLESS proxy configuration."""
uuid: str
server: str
port: int
security: str = "tls"
type: str = "tcp"
sni: str = ""
alpn: str = ""
pbk: str = ""
sid: str = ""
spx: str = ""
path: str = "/"
serviceName: str = ""
host: str = ""
flow: str = ""
@dataclass
class DNSServerConfig:
"""Sing-box DNS server configuration."""
server_type: str # udp, tls, https
server: str
server_port: int = 53
tls_config: Optional[Dict[str, Any]] = None