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