diff --git a/.env.example b/.env.example index dd99017..7c2e579 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ PUBLIC_PATH=change-me-random-hash-path # 例如:https://sub.example.com PUBLIC_BASE_URL= -# 上游这里先放“能直接返回 Clash/Mihomo YAML proxies 文件”的地址 -AIRPORT_A_URL=https://example.com/airport-a.yaml -AIRPORT_B_URL=https://example.com/airport-b.yaml +# airport-a / airport-b 都会自动识别: +# 1. Clash/Mihomo YAML proxies 文件 +# 2. base64 编码 URI 订阅 +# 3. 直接明文 URI 订阅 +AIRPORT_A_URL= +# 允许留空;留空时该机场会自动跳过,不参与默认查询 +AIRPORT_B_URL= diff --git a/README.md b/README.md index 6849762..7b07bd4 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,12 @@ - provider 单源输出、merged provider 输出、thin client 输出、bundle 输出 - 服务端内部继续解耦:抓取、配额头解析、provider 构建、规则加载、profile 组装分层处理 -> 当前版本仍然只支持上游已经能返回 Clash/Mihomo YAML `proxies:` 文件的地址。 -> 这很适合先接你现有的 `sub-wrapper` / `sub-store` / `subconverter(-extended)` 输出。 +> 当前版本会自动识别上游输入格式,支持: +> 1. 已经能返回 Clash/Mihomo YAML `proxies:` 文件的地址 +> 2. base64 编码的 URI 订阅 +> 3. 明文 URI 订阅 +> +> 当前已兼容常见的 `anytls://`、`vless://`、`trojan://`、`ss://`、`vmess://`。 --- @@ -62,7 +66,8 @@ cp .env.example .env - `PUBLIC_PATH` 改成足够长的随机字符串 - `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com` -- `AIRPORT_A_URL` / `AIRPORT_B_URL` 改成你的上游 YAML provider 地址 +- `AIRPORT_A_URL` / `AIRPORT_B_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅 +- 允许把其中一个留空;留空时这个机场会自动跳过 3. 启动: diff --git a/app/main.py b/app/main.py index 005e075..d58cba9 100644 --- a/app/main.py +++ b/app/main.py @@ -34,7 +34,7 @@ def _base_url(request: Request) -> str: def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]: - enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled] + enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled and str(src.url).strip()] if not sources: return enabled @@ -45,7 +45,7 @@ def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]: if name in seen: continue source = app_config.sources.get(name) - if source is None or not source.enabled: + if source is None or not source.enabled or not str(source.url).strip(): raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}") selected.append((name, source)) seen.add(name) diff --git a/app/models.py b/app/models.py index 774f446..867bb7d 100644 --- a/app/models.py +++ b/app/models.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, HttpUrl class SourceConfig(BaseModel): enabled: bool = True - kind: Literal["clash_yaml"] = "clash_yaml" + kind: Literal["auto", "clash_yaml", "base64_uri", "uri"] = "auto" url: str display_name: str | None = None headers: dict[str, str] = Field(default_factory=dict) diff --git a/app/services/subscriptions.py b/app/services/subscriptions.py index 70b1a2c..fbc6430 100644 --- a/app/services/subscriptions.py +++ b/app/services/subscriptions.py @@ -1,7 +1,10 @@ from __future__ import annotations +import base64 +import json import re from typing import Any, Iterable +from urllib.parse import parse_qs, unquote, urlparse import httpx import yaml @@ -45,10 +48,7 @@ async def build_provider_document(name: str, source: SourceConfig) -> ProviderDo fetched = await fetch_source(name, source) - if source.kind != "clash_yaml": - raise ValueError(f"Unsupported source kind: {source.kind}") - - proxies = parse_clash_yaml_proxies(fetched.text) + proxies = parse_source_proxies(fetched.text, source.kind) proxies = transform_proxies(proxies, source, settings.max_proxy_name_length) document = ProviderDocument(proxies=proxies) @@ -134,6 +134,324 @@ def parse_clash_yaml_proxies(text: str) -> list[dict[str, Any]]: return normalized +def parse_source_proxies(text: str, source_kind: str) -> list[dict[str, Any]]: + parsers: dict[str, list] = { + "auto": [parse_clash_yaml_proxies, parse_base64_uri_proxies, parse_uri_text_proxies], + "clash_yaml": [parse_clash_yaml_proxies], + "base64_uri": [parse_base64_uri_proxies, parse_uri_text_proxies], + "uri": [parse_uri_text_proxies, parse_base64_uri_proxies], + } + errors: list[str] = [] + for parser in parsers.get(source_kind, []): + try: + proxies = parser(text) + if proxies: + return proxies + except Exception as exc: # noqa: BLE001 + errors.append(f"{parser.__name__}: {exc}") + detail = "; ".join(errors) if errors else f"unsupported source kind: {source_kind}" + raise ValueError(f"Failed to parse upstream subscription: {detail}") + + +def parse_base64_uri_proxies(text: str) -> list[dict[str, Any]]: + decoded = decode_base64_subscription(text) + return parse_uri_text_proxies(decoded) + + +def parse_uri_text_proxies(text: str) -> list[dict[str, Any]]: + candidates = [ + line.strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + proxies: list[dict[str, Any]] = [] + unsupported: set[str] = set() + + for line in candidates: + if "://" not in line: + continue + + scheme = line.split("://", 1)[0].lower() + if scheme == "anytls": + proxies.append(parse_anytls_uri(line)) + continue + if scheme == "trojan": + proxies.append(parse_trojan_uri(line)) + continue + if scheme == "vless": + proxies.append(parse_vless_uri(line)) + continue + if scheme == "ss": + proxies.append(parse_ss_uri(line)) + continue + if scheme == "vmess": + proxies.append(parse_vmess_uri(line)) + continue + + unsupported.add(scheme) + + if not proxies: + detail = f"unsupported URI schemes: {', '.join(sorted(unsupported))}" if unsupported else "no proxy URIs found" + raise ValueError(f"Base64 subscription parsing failed: {detail}") + + return proxies + + +def decode_base64_subscription(text: str) -> str: + compact = "".join(text.strip().split()) + if not compact: + raise ValueError("Base64 subscription is empty") + padded = compact + ("=" * (-len(compact) % 4)) + try: + return base64.b64decode(padded, validate=False).decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise ValueError("Upstream content is not valid base64 subscription text") from exc + + +def parse_anytls_uri(uri: str) -> dict[str, Any]: + parsed = urlparse(uri) + server = parsed.hostname + port = parsed.port + password = unquote(parsed.username or "") + if not server or not port or not password: + raise ValueError("Invalid anytls URI: missing server, port, or password") + + params = parse_qs(parsed.query, keep_blank_values=False) + proxy: dict[str, Any] = { + "name": unquote(parsed.fragment or f"{server}:{port}"), + "type": "anytls", + "server": server, + "port": port, + "password": password, + "udp": True, + } + + sni = _first_param(params, "sni", "serverName", "servername", "peer") + if sni: + proxy["sni"] = sni + + fingerprint = _first_param(params, "fp", "fingerprint", "client-fingerprint", "clientFingerprint") + if fingerprint: + proxy["client-fingerprint"] = fingerprint + + insecure = _first_param(params, "insecure", "allowInsecure", "skip-cert-verify") + if insecure is not None: + proxy["skip-cert-verify"] = insecure.lower() in {"1", "true", "yes"} + + udp = _first_param(params, "udp") + if udp is not None: + proxy["udp"] = udp.lower() in {"1", "true", "yes"} + + alpn = _first_param(params, "alpn") + if alpn: + proxy["alpn"] = [item.strip() for item in alpn.split(",") if item.strip()] + + for uri_key, proxy_key in ( + ("idle-session-check-interval", "idle-session-check-interval"), + ("idleSessionCheckInterval", "idle-session-check-interval"), + ("idle-session-timeout", "idle-session-timeout"), + ("idleSessionTimeout", "idle-session-timeout"), + ("min-idle-session", "min-idle-session"), + ("minIdleSession", "min-idle-session"), + ): + value = _first_param(params, uri_key) + if value is None: + continue + try: + proxy[proxy_key] = int(value) + except ValueError: + continue + + return proxy + + +def parse_trojan_uri(uri: str) -> dict[str, Any]: + parsed = urlparse(uri) + server = parsed.hostname + port = parsed.port + password = unquote(parsed.username or "") + if not server or not port or not password: + raise ValueError("Invalid trojan URI: missing server, port, or password") + + params = parse_qs(parsed.query, keep_blank_values=False) + proxy: dict[str, Any] = { + "name": unquote(parsed.fragment or f"{server}:{port}"), + "type": "trojan", + "server": server, + "port": port, + "password": password, + "udp": True, + } + _apply_tls_like_params(proxy, params, default_sni=server) + network = _first_param(params, "type", "network") + if network: + proxy["network"] = network + ws_path = _first_param(params, "path") + if ws_path and proxy.get("network") == "ws": + proxy["ws-opts"] = {"path": ws_path} + host = _first_param(params, "host", "Host") + if host: + proxy["ws-opts"]["headers"] = {"Host": host} + return proxy + + +def parse_vless_uri(uri: str) -> dict[str, Any]: + parsed = urlparse(uri) + server = parsed.hostname + port = parsed.port + uuid = unquote(parsed.username or "") + if not server or not port or not uuid: + raise ValueError("Invalid vless URI: missing server, port, or uuid") + + params = parse_qs(parsed.query, keep_blank_values=False) + proxy: dict[str, Any] = { + "name": unquote(parsed.fragment or f"{server}:{port}"), + "type": "vless", + "server": server, + "port": port, + "uuid": uuid, + "udp": True, + } + network = _first_param(params, "type", "network") or "tcp" + proxy["network"] = network + flow = _first_param(params, "flow") + if flow: + proxy["flow"] = flow + if (_first_param(params, "security") or "").lower() == "reality": + public_key = _first_param(params, "pbk", "public-key") + if public_key: + proxy["reality-opts"] = {"public-key": public_key} + short_id = _first_param(params, "sid", "short-id") + if short_id: + proxy["reality-opts"]["short-id"] = short_id + else: + _apply_tls_like_params(proxy, params, default_sni=server) + if network == "ws": + proxy["ws-opts"] = {"path": _first_param(params, "path") or "/"} + host = _first_param(params, "host", "Host") + if host: + proxy["ws-opts"]["headers"] = {"Host": host} + return proxy + + +def parse_ss_uri(uri: str) -> dict[str, Any]: + rest = uri[len("ss://") :] + fragment = "" + if "#" in rest: + rest, fragment = rest.split("#", 1) + query = "" + if "?" in rest: + rest, query = rest.split("?", 1) + + if "@" not in rest: + decoded = decode_base64_subscription(rest) + if "@" not in decoded: + raise ValueError("Invalid ss URI: missing server section") + userinfo, server_part = decoded.rsplit("@", 1) + else: + userinfo, server_part = rest.rsplit("@", 1) + try: + userinfo = decode_base64_subscription(userinfo) + except ValueError: + userinfo = unquote(userinfo) + + if ":" not in userinfo or ":" not in server_part: + raise ValueError("Invalid ss URI: malformed credentials or server") + cipher, password = userinfo.split(":", 1) + server, port_text = server_part.rsplit(":", 1) + params = parse_qs(query, keep_blank_values=False) + + proxy: dict[str, Any] = { + "name": unquote(fragment or f"{server}:{port_text}"), + "type": "ss", + "server": server.strip("[]"), + "port": int(port_text), + "cipher": cipher, + "password": password, + "udp": True, + } + plugin = _first_param(params, "plugin") + if plugin: + proxy["plugin"] = plugin.split(";", 1)[0] + plugin_opts: dict[str, Any] = {} + for item in plugin.split(";")[1:]: + if "=" not in item: + continue + key, value = item.split("=", 1) + plugin_opts[key] = value + if plugin_opts: + proxy["plugin-opts"] = plugin_opts + return proxy + + +def parse_vmess_uri(uri: str) -> dict[str, Any]: + raw = uri[len("vmess://") :] + decoded = decode_base64_subscription(raw) + try: + data = json.loads(decoded) + except json.JSONDecodeError as exc: + raise ValueError("Invalid vmess URI JSON payload") from exc + + server = str(data.get("add") or "").strip() + port = int(str(data.get("port") or "0")) + uuid = str(data.get("id") or "").strip() + if not server or not port or not uuid: + raise ValueError("Invalid vmess URI: missing add, port, or id") + + network = str(data.get("net") or "tcp").strip() or "tcp" + proxy: dict[str, Any] = { + "name": str(data.get("ps") or f"{server}:{port}"), + "type": "vmess", + "server": server, + "port": port, + "uuid": uuid, + "alterId": int(str(data.get("aid") or "0")), + "cipher": "auto", + "udp": True, + "network": network, + } + if str(data.get("tls") or "").lower() == "tls": + proxy["tls"] = True + sni = str(data.get("sni") or data.get("host") or "").strip() + if sni: + proxy["servername"] = sni + if str(data.get("allowInsecure") or "").lower() in {"1", "true"}: + proxy["skip-cert-verify"] = True + if network == "ws": + proxy["ws-opts"] = {"path": str(data.get("path") or "/")} + host = str(data.get("host") or "").strip() + if host: + proxy["ws-opts"]["headers"] = {"Host": host} + return proxy + + +def _apply_tls_like_params(proxy: dict[str, Any], params: dict[str, list[str]], *, default_sni: str | None = None) -> None: + security = (_first_param(params, "security") or "").lower() + if security in {"tls", "xtls"} or any(key in params for key in ("sni", "peer", "allowInsecure", "insecure")): + proxy["tls"] = True + sni = _first_param(params, "sni", "peer", "serverName", "servername") or default_sni + if sni: + proxy["sni"] = sni + proxy["servername"] = sni + insecure = _first_param(params, "insecure", "allowInsecure", "skip-cert-verify") + if insecure is not None: + proxy["skip-cert-verify"] = insecure.lower() in {"1", "true", "yes"} + alpn = _first_param(params, "alpn") + if alpn: + proxy["alpn"] = [item.strip() for item in alpn.split(",") if item.strip()] + fp = _first_param(params, "fp", "fingerprint", "client-fingerprint", "clientFingerprint") + if fp: + proxy["client-fingerprint"] = fp + + +def _first_param(params: dict[str, list[str]], *keys: str) -> str | None: + for key in keys: + values = params.get(key) + if values: + return unquote(values[0]) + return None + + def transform_proxies( proxies: list[dict[str, Any]], source: SourceConfig, max_proxy_name_length: int ) -> list[dict[str, Any]]: diff --git a/config/sources.yaml b/config/sources.yaml index 431bded..cff48ff 100644 --- a/config/sources.yaml +++ b/config/sources.yaml @@ -3,17 +3,17 @@ public_path: ${PUBLIC_PATH} sources: airport-a: enabled: true - display_name: 蛋挞云 - kind: clash_yaml + display_name: A + kind: auto url: ${AIRPORT_A_URL} prefix: "[A] " include_regex: "" exclude_regex: "" airport-b: - enabled: true - display_name: AnyTLS - kind: clash_yaml + enabled: false + display_name: B + kind: auto url: ${AIRPORT_B_URL} prefix: "[B] " include_regex: ""