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
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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`) |
+9 -3
View File
@@ -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()]
+110 -1
View File
@@ -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)]
+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"),
)
+1
View File
@@ -22,6 +22,7 @@ class ProxyInfo:
serviceName: str = ""
host: str = ""
flow: str = ""
fingerprint: str = "chrome"
@dataclass