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
|
FROM ghcr.io/sagernet/sing-box:latest AS sb-bin
|
||||||
# ========== Stage 2: slim Python runtime + entrypoint.py ==========
|
# ========== Stage 2: slim Python runtime + entrypoint.py ==========
|
||||||
FROM python:3.13-slim
|
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
|
COPY --from=sb-bin /usr/local/bin/sing-box /usr/local/bin/sing-box
|
||||||
RUN chmod +x /usr/local/bin/sing-box
|
RUN chmod +x /usr/local/bin/sing-box
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Connect to `socks5://localhost:1080`.
|
|||||||
| `SOCKS_PORT` | no | `1080` | SOCKS5 listen port |
|
| `SOCKS_PORT` | no | `1080` | SOCKS5 listen port |
|
||||||
| `SOCKS_USER` | no | `""` | Auth username (leave empty to skip auth) |
|
| `SOCKS_USER` | no | `""` | Auth username (leave empty to skip auth) |
|
||||||
| `SOCKS_PASS` | no | `""` | Auth password |
|
| `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` |
|
| `PICK_STRATEGY` | no | `first` | Node selection: `first` (default) or `random` |
|
||||||
| `LOG_LEVEL` | no | `info` | sing-box log level (`debug`, `warn`, `error`) |
|
| `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_port: int = 1080,
|
||||||
socks_user: str = "",
|
socks_user: str = "",
|
||||||
socks_pass: str = "",
|
socks_pass: str = "",
|
||||||
dns_server: str = "udp://1.1.1.1",
|
dns_server: str = "udp://10.35.99.172",
|
||||||
log_level: str = "info",
|
log_level: str = "info",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Build a complete sing-box JSON configuration.
|
"""Build a complete sing-box JSON configuration.
|
||||||
@@ -113,13 +113,19 @@ def _build_proxy_outbound(proxy: ProxyInfo) -> Dict[str, Any]:
|
|||||||
"packet_encoding": "xudp",
|
"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"
|
security = proxy.security or "tls"
|
||||||
|
|
||||||
if security == "reality":
|
if security == "reality":
|
||||||
outbound["tls"] = {
|
outbound["tls"] = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"server_name": proxy.sni,
|
"server_name": proxy.sni,
|
||||||
"utls": {"enabled": True, "fingerprint": "chrome"},
|
"utls": {"enabled": True, "fingerprint": fingerprint},
|
||||||
"reality": {
|
"reality": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"public_key": proxy.pbk,
|
"public_key": proxy.pbk,
|
||||||
@@ -130,7 +136,7 @@ def _build_proxy_outbound(proxy: ProxyInfo) -> Dict[str, Any]:
|
|||||||
tls_config: Dict[str, Any] = {
|
tls_config: Dict[str, Any] = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"server_name": proxy.sni,
|
"server_name": proxy.sni,
|
||||||
"utls": {"enabled": True, "fingerprint": "chrome"},
|
"utls": {"enabled": True, "fingerprint": fingerprint},
|
||||||
}
|
}
|
||||||
if proxy.alpn:
|
if proxy.alpn:
|
||||||
tls_config["alpn"] = [a.strip() for a in proxy.alpn.split(",") if a.strip()]
|
tls_config["alpn"] = [a.strip() for a in proxy.alpn.split(",") if a.strip()]
|
||||||
|
|||||||
+110
-1
@@ -3,11 +3,12 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse as up
|
import urllib.parse as up
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from models import ProxyInfo
|
from models import ProxyInfo
|
||||||
|
|
||||||
@@ -152,3 +153,111 @@ def url_to_proxy_info(url_str: str) -> ProxyInfo:
|
|||||||
host=raw["host"],
|
host=raw["host"],
|
||||||
flow=raw["flow"],
|
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)
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
def pick_node(vless_urls: List[str], strategy: str = "first") -> Optional[str]:
|
def pick_node(proxies: List["ProxyInfo"], strategy: str = "first") -> Optional["ProxyInfo"]:
|
||||||
"""Select a VLESS URL from the list based on the given strategy.
|
"""Select a proxy node from the list based on the given strategy.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vless_urls: List of VLESS URL strings.
|
proxies: List of parsed proxy nodes.
|
||||||
strategy: 'first' or 'random'. Defaults to 'first'.
|
strategy: 'first' or 'random'. Defaults to 'first'.
|
||||||
|
|
||||||
Returns:
|
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
|
return None
|
||||||
if strategy == "random":
|
if strategy == "random":
|
||||||
chosen = random.choice(vless_urls)
|
chosen = random.choice(proxies)
|
||||||
else:
|
else:
|
||||||
chosen = vless_urls[0]
|
chosen = proxies[0]
|
||||||
logger.debug("Chosen node URL (truncated): %s", chosen[:60] + "...")
|
logger.debug("Chosen node: %s:%d", chosen.server, chosen.port)
|
||||||
return chosen
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ def run(
|
|||||||
socks_port: int = 1080,
|
socks_port: int = 1080,
|
||||||
socks_user: str = "",
|
socks_user: str = "",
|
||||||
socks_pass: str = "",
|
socks_pass: str = "",
|
||||||
dns_server: str = "udp://1.1.1.1",
|
dns_server: str = "udp://10.35.99.172",
|
||||||
pick_strategy: str = "first",
|
pick_strategy: str = "first",
|
||||||
log_level: str = "info",
|
log_level: str = "info",
|
||||||
config_file_path: str = "/tmp/.sub2socks.json",
|
config_file_path: str = "/tmp/.sub2socks.json",
|
||||||
@@ -83,23 +83,23 @@ def run(
|
|||||||
logger.error("Failed to reach %s: %s", sub_url, exc)
|
logger.error("Failed to reach %s: %s", sub_url, exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
vless_lines = fetcher.extract_vless_lines(sub_data)
|
proxies = fetcher.extract_nodes(sub_data)
|
||||||
if not vless_lines:
|
if not proxies:
|
||||||
logger.error("No VLESS nodes found in subscription!")
|
logger.error("No VLESS nodes found in subscription!")
|
||||||
sys.stdout.write(sub_data[:500] + "\n")
|
sys.stdout.write(sub_data[:500] + "\n")
|
||||||
sys.exit(1)
|
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)
|
proxy = pick_node(proxies, pick_strategy)
|
||||||
if not chosen:
|
if not proxy:
|
||||||
logger.error("Node selection failed.")
|
logger.error("Node selection failed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
proxy = fetcher.url_to_proxy_info(chosen)
|
logger.info("Selected node: %s:%d (security=%s, transport=%s, flow=%s)",
|
||||||
|
proxy.server, proxy.port, proxy.security, proxy.type,
|
||||||
logger.info("Selected node: %s:%d", proxy.server, proxy.port)
|
proxy.flow or "-")
|
||||||
|
|
||||||
singbox_config = config_builder.build_singbox_config(
|
singbox_config = config_builder.build_singbox_config(
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
socks_port=socks_port,
|
socks_port=socks_port,
|
||||||
@@ -159,7 +159,7 @@ def main() -> None:
|
|||||||
socks_port=int(os.environ.get("SOCKS_PORT", "1080")),
|
socks_port=int(os.environ.get("SOCKS_PORT", "1080")),
|
||||||
socks_user=os.environ.get("SOCKS_USER", ""),
|
socks_user=os.environ.get("SOCKS_USER", ""),
|
||||||
socks_pass=os.environ.get("SOCKS_PASS", ""),
|
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"),
|
pick_strategy=os.environ.get("PICK_STRATEGY", "first"),
|
||||||
log_level=os.environ.get("LOG_LEVEL", "info"),
|
log_level=os.environ.get("LOG_LEVEL", "info"),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user