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:
+3
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user