diff --git a/config_builder.py b/config_builder.py new file mode 100644 index 0000000..54b9f19 --- /dev/null +++ b/config_builder.py @@ -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", + } diff --git a/fetcher.py b/fetcher.py new file mode 100644 index 0000000..2957b8d --- /dev/null +++ b/fetcher.py @@ -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"], + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..3a00acd --- /dev/null +++ b/main.py @@ -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() diff --git a/models.py b/models.py new file mode 100644 index 0000000..5dd058e --- /dev/null +++ b/models.py @@ -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