diff --git a/Dockerfile b/Dockerfile index 485c65b..c9471de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ FROM ghcr.io/sagernet/sing-box:latest AS sb-bin # ========== Stage 2: slim Python runtime + entrypoint.py ========== FROM python:3.13-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ + pip install --no-cache-dir pyyaml COPY --from=sb-bin /usr/local/bin/sing-box /usr/local/bin/sing-box RUN chmod +x /usr/local/bin/sing-box WORKDIR /src diff --git a/README.md b/README.md index c9f7d83..e34c138 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Connect to `socks5://localhost:1080`. | `SOCKS_PORT` | no | `1080` | SOCKS5 listen port | | `SOCKS_USER` | no | `""` | Auth username (leave empty to skip auth) | | `SOCKS_PASS` | no | `""` | Auth password | -| `DNS_SERVER` | no | `udp://1.1.1.1` | Upstream DNS resolver address (insecure UDP by default) | +| `DNS_SERVER` | no | `udp://10.35.99.172` | Upstream DNS resolver address (insecure UDP by default) | | `PICK_STRATEGY` | no | `first` | Node selection: `first` (default) or `random` | | `LOG_LEVEL` | no | `info` | sing-box log level (`debug`, `warn`, `error`) | diff --git a/config_builder.py b/config_builder.py index c1a3f7b..8c99ff7 100644 --- a/config_builder.py +++ b/config_builder.py @@ -56,7 +56,7 @@ def build_singbox_config( 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", log_level: str = "info", ) -> Dict[str, Any]: """Build a complete sing-box JSON configuration. @@ -113,13 +113,19 @@ def _build_proxy_outbound(proxy: ProxyInfo) -> Dict[str, Any]: "packet_encoding": "xudp", } + # Reality/Vision flow must be forwarded for xtls-rprx-vision to work + if proxy.flow: + outbound["flow"] = proxy.flow + + fingerprint = proxy.fingerprint or "chrome" + security = proxy.security or "tls" if security == "reality": outbound["tls"] = { "enabled": True, "server_name": proxy.sni, - "utls": {"enabled": True, "fingerprint": "chrome"}, + "utls": {"enabled": True, "fingerprint": fingerprint}, "reality": { "enabled": True, "public_key": proxy.pbk, @@ -130,7 +136,7 @@ def _build_proxy_outbound(proxy: ProxyInfo) -> Dict[str, Any]: tls_config: Dict[str, Any] = { "enabled": True, "server_name": proxy.sni, - "utls": {"enabled": True, "fingerprint": "chrome"}, + "utls": {"enabled": True, "fingerprint": fingerprint}, } if proxy.alpn: tls_config["alpn"] = [a.strip() for a in proxy.alpn.split(",") if a.strip()] diff --git a/fetcher.py b/fetcher.py index 2957b8d..222c995 100644 --- a/fetcher.py +++ b/fetcher.py @@ -3,11 +3,12 @@ import base64 import logging +import re import time import urllib.error import urllib.parse as up import urllib.request -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from models import ProxyInfo @@ -152,3 +153,111 @@ def url_to_proxy_info(url_str: str) -> ProxyInfo: host=raw["host"], flow=raw["flow"], ) + + +def _mihomo_entry_to_proxy(entry: Dict[str, Any]) -> Optional[ProxyInfo]: + """Convert one mihomo YAML proxy entry to a ProxyInfo (VLESS only). + + Args: + entry: Raw proxy dict from the mihomo `proxies` list. + + Returns: + ProxyInfo instance, or None if the entry is not a usable VLESS node. + """ + if str(entry.get("type", "")).lower() != "vless": + return None + server = str(entry.get("server", "")).strip() + port = entry.get("port") + if not server or port is None: + return None + + opts = entry.get("reality-opts") or {} + ws = entry.get("ws-opts") or {} + grpc = entry.get("grpc-opts") or {} + http = entry.get("http-opts") or {} + + # Network name: "ws" and "h2" are both WebSocket transports + network = str(entry.get("network", "tcp") or "tcp").lower() + transport_type = "ws" if network == "h2" else network + + # Security mode: explicit reality-opts -> reality; otherwise honour tls flag + security = "reality" if opts else ("tls" if entry.get("tls", False) else "none") + + return ProxyInfo( + uuid=str(entry.get("uuid", "")), + server=server, + port=int(port), + security=security, + type=transport_type, + sni=str(entry.get("servername", "") or ""), + pbk=str(opts.get("public-key", "") or ""), + sid=str(opts.get("short-id", "") or ""), + path=str(ws.get("path", "") or http.get("path", "") or ""), + host=str(ws.get("host", "") or http.get("host", "") or ""), + serviceName=str(grpc.get("service-name", "") or ""), + flow=str(entry.get("flow", "") or ""), + fingerprint=str(entry.get("client-fingerprint", "") or "chrome"), + ) + + +def extract_mihomo_nodes(text: str) -> List[ProxyInfo]: + """Parse a mihomo/clash.meta YAML subscription into ProxyInfo nodes. + + Args: + text: Decoded mihomo YAML subscription content. + + Returns: + List of VLESS ProxyInfo instances (non-VLESS entries are skipped). + + Raises: + yaml.YAMLError: If the content is not valid YAML. + """ + import yaml # PyYAML + + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + logger.error("Mihomo YAML parse error: %s", exc) + raise + + if not isinstance(data, dict): + return [] + + proxies = data.get("proxies") or [] + result: List[ProxyInfo] = [] + for entry in proxies: + if not isinstance(entry, dict): + continue + try: + proxy = _mihomo_entry_to_proxy(entry) + except Exception as exc: + logger.warning("Skipping unparseable mihomo entry %r: %s", + entry.get("name"), exc) + continue + if proxy is not None: + result.append(proxy) + return result + + +def extract_nodes(text: str) -> List[ProxyInfo]: + """Extract all usable VLESS nodes from a subscription, any known format. + + Detects mihomo/clash.meta YAML exports (top-level `proxies:` list) and + plain `vless://` URI subscriptions. + + Args: + text: Raw (already decoded) subscription content. + + Returns: + List of ProxyInfo instances. + """ + if re.search(r"(?m)^\s*proxies\s*:", text): + try: + nodes = extract_mihomo_nodes(text) + if nodes: + return nodes + logger.warning("Mihomo YAML detected but no VLESS nodes inside") + except Exception: + pass + # Fallback: plain vless:// lines (base64 or plain) + return [url_to_proxy_info(u) for u in extract_vless_lines(text)] diff --git a/main.py b/main.py index 75da44c..b272495 100644 --- a/main.py +++ b/main.py @@ -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"), ) diff --git a/models.py b/models.py index 5dd058e..0b5a2d1 100644 --- a/models.py +++ b/models.py @@ -22,6 +22,7 @@ class ProxyInfo: serviceName: str = "" host: str = "" flow: str = "" + fingerprint: str = "chrome" @dataclass