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:
@@ -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
@@ -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"],
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user