feat: mihomo YAML subscription support + flow/fingerprint passthrough

- fetcher: parse mihomo/clash.meta YAML subscriptions (proxies: list),
  auto-detect format (mihomo YAML vs plain vless:// lines)
- config_builder: forward VLESS flow (xtls-rprx-vision) and per-node
  uTLS fingerprint to sing-box (required for Reality+Vision nodes)
- default DNS upstream: udp://10.35.99.172 (public UDP resolvers
  unreachable on this network; old-sub nodes are hostnames)
- Dockerfile: install PyYAML

Verified: 50-node reality+vision subscription works end-to-end
(sing-box check + live SOCKS tunnel); legacy vless:// subs unaffected.
This commit is contained in:
2026-09-09 10:56:11 +03:00
parent 1beb97e367
commit 56f28099c8
6 changed files with 143 additions and 25 deletions
+19 -19
View File
@@ -31,23 +31,23 @@ def setup_logging(level: str = "info") -> None:
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.
def pick_node(proxies: List["ProxyInfo"], strategy: str = "first") -> Optional["ProxyInfo"]:
"""Select a proxy node from the list based on the given strategy.
Args:
vless_urls: List of VLESS URL strings.
proxies: List of parsed proxy nodes.
strategy: 'first' or 'random'. Defaults to 'first'.
Returns:
Selected VLESS URL string, or None if list is empty.
Selected ProxyInfo, or None if list is empty.
"""
if not vless_urls:
if not proxies:
return None
if strategy == "random":
chosen = random.choice(vless_urls)
chosen = random.choice(proxies)
else:
chosen = vless_urls[0]
logger.debug("Chosen node URL (truncated): %s", chosen[:60] + "...")
chosen = proxies[0]
logger.debug("Chosen node: %s:%d", chosen.server, chosen.port)
return chosen
@@ -56,7 +56,7 @@ def run(
socks_port: int = 1080,
socks_user: str = "",
socks_pass: str = "",
dns_server: str = "udp://1.1.1.1",
dns_server: str = "udp://10.35.99.172",
pick_strategy: str = "first",
log_level: str = "info",
config_file_path: str = "/tmp/.sub2socks.json",
@@ -83,23 +83,23 @@ def run(
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:
proxies = fetcher.extract_nodes(sub_data)
if not proxies:
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)
logger.info("Found %d VLESS node(s), picking one with strategy '%s'",
len(proxies), pick_strategy)
chosen = pick_node(vless_lines, pick_strategy)
if not chosen:
proxy = pick_node(proxies, pick_strategy)
if not proxy:
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)
logger.info("Selected node: %s:%d (security=%s, transport=%s, flow=%s)",
proxy.server, proxy.port, proxy.security, proxy.type,
proxy.flow or "-")
singbox_config = config_builder.build_singbox_config(
proxy=proxy,
socks_port=socks_port,
@@ -159,7 +159,7 @@ def main() -> None:
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", "udp://1.1.1.1"),
dns_server=os.environ.get("DNS_SERVER", "udp://10.35.99.172"),
pick_strategy=os.environ.get("PICK_STRATEGY", "first"),
log_level=os.environ.get("LOG_LEVEL", "info"),
)