diff --git a/README.md b/README.md index 2d54a0b..b733281 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ sub-provider/ rules.py subscriptions.py config/ + conf/ + base.conf + profiles/ + default.conf + lite.conf sources.yaml rules/ reject.yaml @@ -90,10 +95,14 @@ docker compose up -d --build `https://YOUR_DOMAIN//clients/mihomo.yaml?sources=airport-a,airport-b,airport-c` - Stash 薄壳入口: `https://YOUR_DOMAIN//clients/stash.yaml?sources=airport-a,airport-b,airport-c` +- Conf 薄壳入口: + `https://YOUR_DOMAIN//conf/clients/mihomo/default.yaml` - Mihomo/OpenClash bundle: `https://YOUR_DOMAIN//bundle/mihomo.yaml?sources=airport-a,airport-b,airport-c` - Stash bundle: `https://YOUR_DOMAIN//bundle/stash.yaml?sources=airport-a,airport-b,airport-c` +- Conf bundle: + `https://YOUR_DOMAIN//conf/bundle/mihomo/default.yaml` --- @@ -152,6 +161,77 @@ HEAD //bundle/stash.yaml?sources=airport-a,airport-b,airport-c - 上游订阅原始内容也会按 TTL 落盘缓存到 `data/fetch-cache/`,默认 900 秒 - 如果你想固定使用本地文件订阅,可以把文件放到 `data/sources/`,再把 `AIRPORT_*_URL` 指向 `/app/data/sources/xxx.txt` +### 5. Conf 配置入口 + +```text +GET //conf/clients/{client_type}/{profile_key}.yaml +GET //conf/bundle/{client_type}/{profile_key}.yaml +GET //conf/providers/{profile_key}/{name}.yaml +GET //conf/rules/{profile_key}/{name}.yaml +``` + +特点: + +- 旧 `sources.yaml` 路由保持不变,conf 路由独立存在 +- 新链路读取 `config/conf/base.conf` 和 `config/conf/profiles/*.conf` +- 可以并行对比旧输出和 conf 输出,不影响日常使用 +- conf 路由按请求懒加载配置;conf 写坏时不会影响旧接口启动 + +--- + +## Conf 配置说明 + +当前已落地一套 ACL4SSR-like 的 `.conf` 配置链路: + +- `config/conf/base.conf` + 负责 `source`、`selector`、`group`、`module`、`builtin` 等全局定义 +- `config/conf/profiles/*.conf` + 负责 `sources`、`include_modules`、`include_builtins` + 以及 `override_policy`、`prepend_rule`、`append_rule` + +这条链路目前的目标不是替换旧接口,而是: + +- 保持旧 YAML 配置和旧路由继续可用 +- 用新 conf 路由做并行验证 +- 逐步把输出收敛到和旧链路一致 + +### 示例 + +`base.conf` 里一条规则模块: + +```ini +module = apple,../rules/acl4ssr/Apple.yaml,🍎 苹果服务,80,true +``` + +`default.conf` 里启用模块: + +```ini +include_modules = apple,openai +include_builtins = geoip_cn,final +``` + +--- + +## 对比脚本 + +项目内置了一个本地对比脚本: + +```bash +python scripts/compare_conf_outputs.py +``` + +它会: + +- 启动本地 `TestClient` +- 同时请求旧路由和 conf 路由 +- 输出 `proxy-providers`、`rule-providers`、`proxy-groups`、`rules` 的差异摘要 + +如果要指定样例源文件: + +```bash +python scripts/compare_conf_outputs.py --sample-source C:\path\to\sample.yaml +``` + --- ## 默认策略结构 diff --git a/app/conf_models.py b/app/conf_models.py index df6e0d4..8408bb8 100644 --- a/app/conf_models.py +++ b/app/conf_models.py @@ -67,3 +67,81 @@ class ConfBaseConfig(BaseModel): groups: list[ConfGroup] = Field(default_factory=list) modules: list[ConfModule] = Field(default_factory=list) builtins: list[ConfBuiltin] = Field(default_factory=list) + + +class ConfPolicyOverride(BaseModel): + module_key: str + policy: str + line_no: int + + +class ConfInlineRule(BaseModel): + value: str + line_no: int + + +class ConfProfileConfig(BaseModel): + name: str | None = None + enabled: bool = True + sources: list[str] = Field(default_factory=list) + include_modules: list[str] = Field(default_factory=list) + exclude_modules: list[str] = Field(default_factory=list) + include_builtins: list[str] = Field(default_factory=list) + override_policies: list[ConfPolicyOverride] = Field(default_factory=list) + prepend_rules: list[ConfInlineRule] = Field(default_factory=list) + append_rules: list[ConfInlineRule] = Field(default_factory=list) + + +class ConfResolvedSource(BaseModel): + key: str + display_name: str + source_type: str + value: str + enabled: bool = True + cache_ttl: int | None = None + options: dict[str, str] = Field(default_factory=dict) + + +class ConfResolvedModule(BaseModel): + key: str + path: str + policy: str + order: int + enabled: bool = True + + +class ConfResolvedBuiltin(BaseModel): + key: str + rule_line: str + policy: str + order: int + enabled: bool = True + + +class ConfResolvedGroup(BaseModel): + name: str + group_type: str + members: list[str] = Field(default_factory=list) + selector_key: str | None = None + url: str | None = None + interval: int | None = None + tolerance: int | None = None + raw: str + + +class ConfRuntimePlan(BaseModel): + profile_key: str + profile_name: str | None = None + selected_sources: list[ConfResolvedSource] = Field(default_factory=list) + selected_modules: list[ConfResolvedModule] = Field(default_factory=list) + selected_builtins: list[ConfResolvedBuiltin] = Field(default_factory=list) + selectors: dict[str, str] = Field(default_factory=dict) + groups: list[ConfResolvedGroup] = Field(default_factory=list) + prepend_rules: list[str] = Field(default_factory=list) + append_rules: list[str] = Field(default_factory=list) + mode: str | None = None + allow_lan: bool | None = None + log_level: str | None = None + ipv6: bool | None = None + append_userinfo_header: bool | None = None + userinfo_source_policy: str | None = None diff --git a/app/config.py b/app/config.py index 2266b92..c88f9c0 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,7 @@ class Settings(BaseSettings): sources_file: Path = CONFIG_DIR / "sources.yaml" conf_base_file: Path = CONFIG_DIR / "conf" / "base.conf" + conf_profiles_dir: Path = CONFIG_DIR / "conf" / "profiles" rules_dir: Path = CONFIG_DIR / "rules" bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache" fetch_cache_dir: Path = DATA_DIR / "fetch-cache" diff --git a/app/main.py b/app/main.py index eea1bf0..53c3d7d 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,10 @@ from fastapi.responses import Response from app.config import get_settings from app.models import RuleConfig, SourceConfig, SourceSnapshot from app.services.bundle_cache import build_bundle_cache_key, load_bundle_cache, save_bundle_cache +from app.services.conf_config_store import load_conf_store +from app.services.conf_loader import ConfConfigError +from app.services.conf_profiles import build_conf_bundle_profile, build_conf_thin_profile, conf_source_to_source_config +from app.services.conf_runtime import build_conf_rule_lines, resolve_conf_runtime_plan from app.services.loader import load_app_config from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml from app.services.rules import load_rule_text @@ -69,6 +73,37 @@ def _rule_path(rule: RuleConfig): return path +def _load_conf_profile_runtime(profile_key: str): + try: + base_config, profiles = load_conf_store() + except ConfConfigError as exc: + raise HTTPException(status_code=500, detail=f"invalid conf config: {exc}") from exc + + profile = profiles.get(profile_key) + if profile is None: + raise HTTPException(status_code=404, detail="conf profile not found") + try: + plan = resolve_conf_runtime_plan( + profile_key=profile_key, + base_config=base_config, + profile_config=profile, + base_path=settings.conf_base_file, + ) + except ConfConfigError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return base_config, profile, plan + + +def _conf_source_items(plan) -> list[tuple[str, SourceConfig]]: + items: list[tuple[str, SourceConfig]] = [] + for source in plan.selected_sources: + try: + items.append((source.key, conf_source_to_source_config(source, base_path=settings.conf_base_file))) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return items + + async def _build_quota_headers(source_items: list[tuple[str, SourceConfig]]) -> dict[str, str]: headers: dict[str, str] = {} quota = await get_first_quota(source_items) @@ -161,6 +196,58 @@ async def client_profile(client_type: str, request: Request, sources: str | None return _yaml_response(content, request, headers=headers, filename=f"{client_type}.yaml") +@app.api_route(PUBLIC_PREFIX + "/conf/rules/{profile_key}/{name}.yaml", methods=["GET", "HEAD"]) +async def conf_rule_file(profile_key: str, name: str, request: Request) -> Response: + _, _, plan = _load_conf_profile_runtime(profile_key) + module = next((item for item in plan.selected_modules if item.key == name), None) + if module is None: + raise HTTPException(status_code=404, detail="conf rule not found") + path = (settings.conf_base_file.resolve().parent / module.path).resolve() + if not path.is_file(): + raise HTTPException(status_code=404, detail="conf rule file missing") + content = load_rule_text(path) + return _yaml_response(content, request, filename=f"{profile_key}-{name}.yaml") + + +@app.api_route(PUBLIC_PREFIX + "/conf/providers/{profile_key}/{name}.yaml", methods=["GET", "HEAD"]) +async def conf_provider(profile_key: str, name: str, request: Request) -> Response: + _, _, plan = _load_conf_profile_runtime(profile_key) + source_items = _conf_source_items(plan) + source = next((item for item in source_items if item[0] == name), None) + if source is None: + raise HTTPException(status_code=404, detail="conf provider not found") + try: + document = await build_provider_document(source[0], source[1]) + except Exception as exc: # noqa: BLE001 + logger.exception("conf provider failed: profile=%s source=%s", profile_key, name) + raise HTTPException(status_code=502, detail=f"failed to build conf provider: {exc}") from exc + content = dump_provider_yaml(document) + headers = await _build_quota_headers([source]) + return _yaml_response(content, request, headers=headers, filename=f"{profile_key}-{name}.yaml") + + +@app.api_route(PUBLIC_PREFIX + "/conf/clients/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"]) +async def conf_client_profile(client_type: str, profile_key: str, request: Request) -> Response: + client = app_config.clients.get(client_type) + if client is None: + raise HTTPException(status_code=404, detail="client config not found") + _, _, plan = _load_conf_profile_runtime(profile_key) + source_items = _conf_source_items(plan) + content = dump_yaml( + build_conf_thin_profile( + client_type=client_type, + client=client, + plan=plan, + base_path=settings.conf_base_file, + base_url=_base_url(request), + public_path=(app_config.public_path or settings.public_path).strip("/"), + ) + ) + headers = {"profile-update-interval": str(client.provider_interval)} + headers.update(await _build_quota_headers(source_items)) + return _yaml_response(content, request, headers=headers, filename=f"{client_type}-{profile_key}.yaml") + + @app.api_route(PUBLIC_PREFIX + "/bundle/{client_type}.yaml", methods=["GET", "HEAD"]) async def bundle_profile( client_type: str, @@ -215,3 +302,58 @@ async def bundle_profile( headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"}, ) return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml") + + +@app.api_route(PUBLIC_PREFIX + "/conf/bundle/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"]) +async def conf_bundle_profile(client_type: str, profile_key: str, request: Request, force_refresh: bool = Query(default=False)) -> Response: + client = app_config.clients.get(client_type) + if client is None: + raise HTTPException(status_code=404, detail="client config not found") + _, _, plan = _load_conf_profile_runtime(profile_key) + source_items = _conf_source_items(plan) + cache_key = build_bundle_cache_key( + client_type=f"conf-{client_type}-{profile_key}", + source_names=[name for name, _ in source_items], + ) + if not force_refresh: + cached = load_bundle_cache( + cache_dir=settings.bundle_cache_dir, + cache_key=cache_key, + ttl_seconds=settings.bundle_cache_ttl_seconds, + ) + if cached is not None: + headers = { + "profile-update-interval": str(client.provider_interval), + "X-Sub-Provider-Bundle-Cache": "HIT", + } + headers.update(cached.headers) + return _yaml_response(content=cached.content, request=request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml") + + try: + snapshots = await build_source_snapshots(source_items) + rules = build_conf_rule_lines(plan, base_path=settings.conf_base_file) + except Exception as exc: # noqa: BLE001 + logger.exception("conf_bundle_profile failed: client=%s profile=%s", client_type, profile_key) + raise HTTPException(status_code=502, detail=f"failed to build conf bundle: {exc}") from exc + + content = dump_yaml( + build_conf_bundle_profile( + client_type=client_type, + client=client, + plan=plan, + snapshots=snapshots, + rules=rules, + ) + ) + headers = { + "profile-update-interval": str(client.provider_interval), + "X-Sub-Provider-Bundle-Cache": "BYPASS" if force_refresh else "MISS", + } + headers.update(_quota_headers_from_snapshots(snapshots)) + save_bundle_cache( + cache_dir=settings.bundle_cache_dir, + cache_key=cache_key, + content=content, + headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"}, + ) + return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml") diff --git a/app/services/conf_config_store.py b/app/services/conf_config_store.py new file mode 100644 index 0000000..fd01fa5 --- /dev/null +++ b/app/services/conf_config_store.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from app.config import get_settings +from app.conf_models import ConfBaseConfig, ConfProfileConfig +from app.services.conf_loader import load_conf_base, load_conf_profiles + + +def load_conf_store() -> tuple[ConfBaseConfig, dict[str, ConfProfileConfig]]: + settings = get_settings() + base = load_conf_base(settings.conf_base_file) + profiles = load_conf_profiles(settings.conf_profiles_dir, base_config=base) + return base, profiles diff --git a/app/services/conf_loader.py b/app/services/conf_loader.py index 56f91fc..6aaf225 100644 --- a/app/services/conf_loader.py +++ b/app/services/conf_loader.py @@ -6,7 +6,17 @@ import re from pathlib import Path from typing import Any -from app.conf_models import ConfBaseConfig, ConfBuiltin, ConfGroup, ConfModule, ConfSelector, ConfSource +from app.conf_models import ( + ConfBaseConfig, + ConfBuiltin, + ConfGroup, + ConfInlineRule, + ConfModule, + ConfPolicyOverride, + ConfProfileConfig, + ConfSelector, + ConfSource, +) _ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}") _SCALAR_KEYS = { @@ -23,6 +33,15 @@ _SCALAR_KEYS = { _BOOL_KEYS = {"allow_lan", "ipv6", "append_userinfo_header"} _MULTI_KEYS = {"source", "selector", "group", "module", "builtin"} _SOURCE_TYPES = {"url", "file", "inline", "base64"} +_PROFILE_SCALAR_KEYS = { + "name", + "enabled", + "sources", + "include_modules", + "exclude_modules", + "include_builtins", +} +_PROFILE_MULTI_KEYS = {"override_policy", "prepend_rule", "append_rule"} class ConfConfigError(ValueError): @@ -60,6 +79,25 @@ def _parse_csv_fields(value: str, *, path: Path, line_no: int) -> list[str]: return [field.strip() for field in row] +def _split_key_value(raw_line: str, *, path: Path, line_no: int) -> tuple[str, str]: + if "=" not in raw_line: + raise ConfConfigError(path, line_no, "expected key = value") + key, value = [part.strip() for part in raw_line.split("=", 1)] + if not key: + raise ConfConfigError(path, line_no, "key cannot be empty") + return key, value + + +def _iter_conf_lines(path: Path) -> list[tuple[int, str]]: + records: list[tuple[int, str]] = [] + for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + records.append((line_no, raw_line)) + return records + + def _ensure_named_records(records: list[Any], *, key: str, path: Path) -> None: seen: dict[str, int] = {} for record in records: @@ -175,20 +213,75 @@ def _parse_builtin(value: str, *, path: Path, line_no: int) -> ConfBuiltin: ) +def _parse_string_list(value: str, *, path: Path, line_no: int) -> list[str]: + fields = _parse_csv_fields(_expand_env(value), path=path, line_no=line_no) + return [field for field in fields if field] + + +def _parse_override_policy(value: str, *, path: Path, line_no: int) -> ConfPolicyOverride: + fields = _parse_csv_fields(value, path=path, line_no=line_no) + if len(fields) != 2: + raise ConfConfigError(path, line_no, "override_policy requires exactly 2 fields: module_key,new_policy") + module_key, policy = fields + if not module_key or not policy: + raise ConfConfigError(path, line_no, "override_policy module key and policy cannot be empty") + return ConfPolicyOverride(module_key=module_key, policy=policy, line_no=line_no) + + +def _parse_inline_rule(value: str, *, path: Path, line_no: int, key: str) -> ConfInlineRule: + expanded = _expand_env(value).strip() + if not expanded: + raise ConfConfigError(path, line_no, f"{key} cannot be empty") + return ConfInlineRule(value=expanded, line_no=line_no) + + +def _validate_profile_references( + profile: ConfProfileConfig, + base: ConfBaseConfig, + *, + path: Path, + field_lines: dict[str, int], +) -> None: + source_keys = {item.key for item in base.sources} + module_keys = {item.key for item in base.modules} + builtin_keys = {item.key for item in base.builtins} + + for source in profile.sources: + if source not in source_keys: + raise ConfConfigError(path, field_lines.get("sources", 1), f"profile references unknown source: {source}") + for module in profile.include_modules: + if module not in module_keys: + raise ConfConfigError( + path, + field_lines.get("include_modules", 1), + f"profile references unknown module in include_modules: {module}", + ) + for module in profile.exclude_modules: + if module not in module_keys: + raise ConfConfigError( + path, + field_lines.get("exclude_modules", 1), + f"profile references unknown module in exclude_modules: {module}", + ) + for builtin in profile.include_builtins: + if builtin not in builtin_keys: + raise ConfConfigError( + path, + field_lines.get("include_builtins", 1), + f"profile references unknown builtin in include_builtins: {builtin}", + ) + for override in profile.override_policies: + if override.module_key not in module_keys: + raise ConfConfigError(path, override.line_no, f"override_policy references unknown module: {override.module_key}") + + def load_conf_base(path: Path) -> ConfBaseConfig: config = ConfBaseConfig() scalar_values: dict[str, Any] = {} config_dir = path.resolve().parent - for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#") or line.startswith(";"): - continue - if "=" not in raw_line: - raise ConfConfigError(path, line_no, "expected key = value") - key, value = [part.strip() for part in raw_line.split("=", 1)] - if not key: - raise ConfConfigError(path, line_no, "key cannot be empty") + for line_no, raw_line in _iter_conf_lines(path): + key, value = _split_key_value(raw_line, path=path, line_no=line_no) if key in _SCALAR_KEYS: if key in scalar_values: raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}") @@ -220,3 +313,48 @@ def load_conf_base(path: Path) -> ConfBaseConfig: _ensure_named_records(config.modules, key="key", path=path) _ensure_named_records(config.builtins, key="key", path=path) return config + + +def load_conf_profile(path: Path, *, base_config: ConfBaseConfig) -> ConfProfileConfig: + profile = ConfProfileConfig() + scalar_values: dict[str, Any] = {} + field_lines: dict[str, int] = {} + + for line_no, raw_line in _iter_conf_lines(path): + key, value = _split_key_value(raw_line, path=path, line_no=line_no) + if key in _PROFILE_SCALAR_KEYS: + if key in scalar_values: + raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}") + field_lines[key] = line_no + if key == "enabled": + scalar_values[key] = _parse_bool(_expand_env(value), path=path, line_no=line_no, key=key) + elif key in {"sources", "include_modules", "exclude_modules", "include_builtins"}: + scalar_values[key] = _parse_string_list(value, path=path, line_no=line_no) + else: + scalar_values[key] = _expand_env(value) + continue + if key not in _PROFILE_MULTI_KEYS: + raise ConfConfigError(path, line_no, f"unknown directive: {key}") + if key == "override_policy": + profile.override_policies.append(_parse_override_policy(value, path=path, line_no=line_no)) + elif key == "prepend_rule": + profile.prepend_rules.append(_parse_inline_rule(value, path=path, line_no=line_no, key=key)) + elif key == "append_rule": + profile.append_rules.append(_parse_inline_rule(value, path=path, line_no=line_no, key=key)) + + for key, parsed_value in scalar_values.items(): + setattr(profile, key, parsed_value) + + _validate_profile_references(profile, base_config, path=path, field_lines=field_lines) + return profile + + +def load_conf_profiles(directory: Path, *, base_config: ConfBaseConfig) -> dict[str, ConfProfileConfig]: + profiles: dict[str, ConfProfileConfig] = {} + for path in sorted(directory.glob("*.conf")): + profile = load_conf_profile(path, base_config=base_config) + profile_key = path.stem + if profile_key in profiles: + raise ConfConfigError(path, 1, f"duplicate profile file key: {profile_key}") + profiles[profile_key] = profile + return profiles diff --git a/app/services/conf_profiles.py b/app/services/conf_profiles.py new file mode 100644 index 0000000..a4449e9 --- /dev/null +++ b/app/services/conf_profiles.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from app.conf_models import ConfResolvedModule, ConfRuntimePlan +from app.models import ClientConfig, SourceConfig, SourceSnapshot +from app.services.rules import load_rule_payload + + +def _source_auto_group_name(source: Any) -> str: + display_name = source["display_name"] if isinstance(source, dict) else source.display_name + return f"{display_name} 自动" + + +def conf_source_to_source_config(source: dict[str, Any] | Any, *, base_path: Path) -> SourceConfig: + source_type = source["source_type"] if isinstance(source, dict) else source.source_type + value = source["value"] if isinstance(source, dict) else source.value + options = source["options"] if isinstance(source, dict) else source.options + cache_ttl = source["cache_ttl"] if isinstance(source, dict) else source.cache_ttl + key = source["key"] if isinstance(source, dict) else source.key + + if source_type not in {"url", "file"}: + raise ValueError(f"Unsupported conf source type for runtime route: {source_type}") + + resolved_value = value + if source_type == "file": + candidate = Path(value) + if not candidate.is_absolute(): + candidate = (base_path.resolve().parent / candidate).resolve() + resolved_value = str(candidate) + + kind = str(options.get("kind", "auto")).strip() or "auto" + display_name = str(options.get("display_name", key)).strip() or key + prefix = str(options.get("prefix", "")).strip() + suffix = str(options.get("suffix", "")).strip() + include_regex = str(options.get("include_regex", "")).strip() or None + exclude_regex = str(options.get("exclude_regex", "")).strip() or None + headers: dict[str, str] = {} + for option_key, option_value in options.items(): + if option_key.startswith("header."): + header_name = option_key.split(".", 1)[1].strip() + if header_name: + headers[header_name] = option_value + + return SourceConfig( + enabled=True, + kind=kind, + url=resolved_value, + display_name=display_name, + headers=headers, + include_regex=include_regex, + exclude_regex=exclude_regex, + prefix=prefix, + suffix=suffix, + cache_ttl_seconds=cache_ttl, + ) + + +def _module_rule_provider_entry( + module: ConfResolvedModule, + *, + client: ClientConfig, + base_url: str, + public_path: str, + profile_key: str, +) -> dict[str, Any]: + suffix = Path(module.path).suffix.lower() + return { + "behavior": "classical", + "format": "yaml" if suffix in {".yaml", ".yml"} else "text", + "url": f"{base_url}/{public_path}/conf/rules/{profile_key}/{module.key}.yaml", + "interval": client.rule_interval, + } + + +def _is_inline_module(module: ConfResolvedModule) -> bool: + return Path(module.path).suffix.lower() not in {".yaml", ".yml"} + + +def build_conf_rule_provider_entries( + plan: ConfRuntimePlan, + *, + client: ClientConfig, + base_url: str, + public_path: str, +) -> dict[str, dict[str, Any]]: + return { + module.key: _module_rule_provider_entry(module, client=client, base_url=base_url, public_path=public_path, profile_key=plan.profile_key) + for module in plan.selected_modules + if not _is_inline_module(module) + } + + +def _render_inline_rule(payload_line: str, policy: str) -> str: + rendered = payload_line.strip() + if not rendered: + return "" + if rendered.endswith(",no-resolve"): + parts = [part.strip() for part in rendered.split(",")] + parts.insert(len(parts) - 1, policy) + return ",".join(parts) + return f"{rendered},{policy}" + + +def build_conf_rule_set_references(plan: ConfRuntimePlan, *, base_path: Path) -> list[str]: + config_dir = base_path.resolve().parent + lines: list[str] = [] + final_lines: list[str] = [] + for rule in plan.prepend_rules: + if rule.startswith("MATCH,"): + final_lines.append(rule) + else: + lines.append(rule) + for module in plan.selected_modules: + if _is_inline_module(module): + module_path = (config_dir / module.path).resolve() + for payload_line in load_rule_payload(module_path): + rendered = _render_inline_rule(payload_line, module.policy) + if rendered: + lines.append(rendered) + else: + lines.append(f"RULE-SET,{module.key},{module.policy}") + for builtin in plan.selected_builtins: + if builtin.rule_line.startswith("MATCH,"): + final_lines.append(builtin.rule_line) + else: + lines.append(builtin.rule_line) + for rule in plan.append_rules: + if rule.startswith("MATCH,"): + final_lines.append(rule) + else: + lines.append(rule) + return [*lines, *final_lines] + + +def _build_group_for_thin( + group: Any, + *, + client_type: str, + client: ClientConfig, + selected_source_names: list[str], + source_auto_names: list[str], + selectors: dict[str, str], +) -> dict[str, Any]: + built: dict[str, Any] = { + "name": group.name, + "type": group.group_type, + } + if group.selector_key: + built["filter"] = selectors[group.selector_key] + if client_type == "mihomo": + built["use"] = selected_source_names + else: + built["include-all"] = True + else: + proxies: list[str] = [] + include_all = False + for member in group.members: + if member == "@source_auto_groups": + proxies.extend(source_auto_names) + elif member == "@all_proxies": + include_all = True + else: + proxies.append(member) + built["proxies"] = proxies + if include_all: + if client_type == "mihomo": + built["include-all-providers"] = True + else: + built["include-all"] = True + if group.group_type != "select": + built["url"] = group.url or str(client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + return built + + +def _build_group_for_bundle( + group: Any, + *, + client: ClientConfig, + selectors: dict[str, str], + all_proxy_names: list[str], + source_auto_names: list[str], +) -> dict[str, Any]: + built: dict[str, Any] = { + "name": group.name, + "type": group.group_type, + } + if group.selector_key: + pattern = selectors[group.selector_key] + built["proxies"] = [name for name in all_proxy_names if re.search(pattern, name)] or [client.direct_policy] + else: + proxies: list[str] = [] + for member in group.members: + if member == "@source_auto_groups": + proxies.extend(source_auto_names) + continue + if member == "@all_proxies": + proxies.extend(all_proxy_names) + continue + else: + proxies.append(member) + built["proxies"] = proxies or [client.direct_policy] + if group.group_type != "select": + built["url"] = group.url or str(client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + return built + + +def build_conf_thin_profile( + *, + client_type: str, + client: ClientConfig, + plan: ConfRuntimePlan, + base_path: Path, + base_url: str, + public_path: str, +) -> dict[str, Any]: + profile: dict[str, Any] = { + "mode": plan.mode or client.mode, + "ipv6": client.ipv6 if plan.ipv6 is None else plan.ipv6, + "proxy-providers": {}, + } + log_level = plan.log_level or client.log_level + if log_level: + profile["log-level"] = log_level + if client_type == "mihomo": + if client.mixed_port is not None: + profile["mixed-port"] = client.mixed_port + if client.socks_port is not None: + profile["socks-port"] = client.socks_port + profile["allow-lan"] = client.allow_lan if plan.allow_lan is None else plan.allow_lan + + selected_source_names = [item.key for item in plan.selected_sources] + source_auto_names = [_source_auto_group_name(item) for item in plan.selected_sources] + for name in selected_source_names: + source = next(item for item in plan.selected_sources if item.key == name) + group_name = _source_auto_group_name(source) + if client_type == "mihomo": + profile["proxy-providers"][name] = { + "type": "http", + "url": f"{base_url}/{public_path}/conf/providers/{plan.profile_key}/{name}.yaml", + "path": f"./providers/{plan.profile_key}-{name}.yaml", + "interval": client.provider_interval, + "health-check": { + "enable": True, + "url": str(client.test_url), + "interval": client.test_interval, + }, + } + else: + profile["proxy-providers"][name] = { + "url": f"{base_url}/{public_path}/conf/providers/{plan.profile_key}/{name}.yaml", + "interval": client.provider_interval, + } + profile.setdefault("proxy-groups", []).append( + { + "name": group_name, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "use": [name], + } + ) + + profile["proxy-groups"].extend( + [ + _build_group_for_thin( + group, + client_type=client_type, + client=client, + selected_source_names=selected_source_names, + source_auto_names=source_auto_names, + selectors=plan.selectors, + ) + for group in plan.groups + ] + ) + profile["rule-providers"] = build_conf_rule_provider_entries(plan, client=client, base_url=base_url, public_path=public_path) + profile["rules"] = build_conf_rule_set_references(plan, base_path=base_path) + return profile + + +def build_conf_bundle_profile(*, client_type: str, client: ClientConfig, plan: ConfRuntimePlan, snapshots: list[SourceSnapshot], rules: list[str]) -> dict[str, Any]: + profile: dict[str, Any] = { + "mode": plan.mode or client.mode, + "ipv6": client.ipv6 if plan.ipv6 is None else plan.ipv6, + } + log_level = plan.log_level or client.log_level + if log_level: + profile["log-level"] = log_level + if client_type == "mihomo": + if client.mixed_port is not None: + profile["mixed-port"] = client.mixed_port + if client.socks_port is not None: + profile["socks-port"] = client.socks_port + profile["allow-lan"] = client.allow_lan if plan.allow_lan is None else plan.allow_lan + + proxies: list[dict[str, Any]] = [] + all_proxy_names: list[str] = [] + source_proxy_names: dict[str, list[str]] = {} + seen: set[str] = set() + for snapshot in snapshots: + current_source_names: list[str] = [] + for proxy in snapshot.document.proxies: + candidate = dict(proxy) + name = str(candidate.get("name", "")).strip() + if not name: + continue + original = name + index = 2 + while name in seen: + name = f"{original} #{index}" + index += 1 + seen.add(name) + candidate["name"] = name + proxies.append(candidate) + all_proxy_names.append(name) + current_source_names.append(name) + source_proxy_names[snapshot.name] = current_source_names + + profile["proxies"] = proxies + profile["proxy-groups"] = [] + for source in plan.selected_sources: + profile["proxy-groups"].append( + { + "name": _source_auto_group_name(source), + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "proxies": source_proxy_names.get(source.key) or [client.direct_policy], + } + ) + profile["proxy-groups"].extend( + [ + _build_group_for_bundle( + group, + client=client, + selectors=plan.selectors, + all_proxy_names=all_proxy_names, + source_auto_names=[_source_auto_group_name(item) for item in plan.selected_sources], + ) + for group in plan.groups + ] + ) + profile["rules"] = rules + return profile diff --git a/app/services/conf_runtime.py b/app/services/conf_runtime.py new file mode 100644 index 0000000..0377bba --- /dev/null +++ b/app/services/conf_runtime.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from pathlib import Path + +from app.conf_models import ( + ConfBaseConfig, + ConfGroup, + ConfProfileConfig, + ConfResolvedBuiltin, + ConfResolvedGroup, + ConfResolvedModule, + ConfResolvedSource, + ConfRuntimePlan, +) +from app.services.conf_loader import ConfConfigError +from app.services.rules import load_rule_payload + +_GROUP_TYPES = {"select", "url-test", "fallback", "load-balance"} +_CIDR_CHARS = set("0123456789abcdefABCDEF:./") +_SELECT_SPECIAL_MEMBERS = {"@source_auto_groups", "@all_proxies"} + + +def _parse_group_member(token: str) -> str: + if token.startswith("[]"): + return token[2:] + return token + + +def _parse_group(group: ConfGroup, *, selectors: dict[str, str], known_group_names: set[str], path: Path) -> ConfResolvedGroup: + if group.group_type not in _GROUP_TYPES: + raise ConfConfigError(path, group.line_no, f"unsupported group type: {group.group_type}") + if group.group_type == "select": + selector_key: str | None = None + members = [_parse_group_member(token) for token in group.tokens if token.strip()] + if not members: + raise ConfConfigError(path, group.line_no, "select group must define at least one member") + for token in group.tokens: + stripped = token.strip() + if stripped.startswith("@"): + if stripped in _SELECT_SPECIAL_MEMBERS: + continue + selector_key = stripped[1:] + if selector_key not in selectors: + raise ConfConfigError(path, group.line_no, f"group references unknown selector: {selector_key}") + continue + if stripped.startswith("[]"): + target = stripped[2:] + if target not in {"DIRECT", "REJECT"} and target not in known_group_names: + raise ConfConfigError(path, group.line_no, f"group references unknown group: {target}") + return ConfResolvedGroup( + name=group.name, + group_type=group.group_type, + members=[] if selector_key else members, + selector_key=selector_key, + raw=group.raw, + ) + + if not group.tokens: + raise ConfConfigError(path, group.line_no, f"{group.group_type} group requires selector/member arguments") + + first = group.tokens[0].strip() + selector_key: str | None = None + members: list[str] = [] + if first.startswith("@"): + selector_key = first[1:] + if selector_key not in selectors: + raise ConfConfigError(path, group.line_no, f"group references unknown selector: {selector_key}") + else: + members.append(_parse_group_member(first)) + if first.startswith("[]"): + target = first[2:] + if target not in {"DIRECT", "REJECT"} and target not in known_group_names: + raise ConfConfigError(path, group.line_no, f"group references unknown group: {target}") + + url = group.tokens[1].strip() if len(group.tokens) > 1 and group.tokens[1].strip() else None + interval: int | None = None + tolerance: int | None = None + if len(group.tokens) > 2 and group.tokens[2].strip(): + try: + interval = int(group.tokens[2].split(",", 1)[0].strip()) + except ValueError as exc: + raise ConfConfigError(path, group.line_no, f"invalid group interval: {group.tokens[2]}") from exc + if len(group.tokens) > 3 and group.tokens[3].strip(): + tolerance_token = group.tokens[3].split(",")[-1].strip() + if tolerance_token: + try: + tolerance = int(tolerance_token) + except ValueError as exc: + raise ConfConfigError(path, group.line_no, f"invalid group tolerance: {group.tokens[3]}") from exc + + return ConfResolvedGroup( + name=group.name, + group_type=group.group_type, + members=members, + selector_key=selector_key, + url=url, + interval=interval, + tolerance=tolerance, + raw=group.raw, + ) + + +def _resolve_builtin_rule_line(builtin_type: str, value: str, policy: str) -> str: + if builtin_type == "GEOIP": + return f"GEOIP,{value},{policy}" + return f"MATCH,{policy}" + + +def _render_rule_line(payload_line: str, policy: str) -> str: + rendered = payload_line.strip() + if not rendered: + return "" + if "," not in rendered: + if "/" in rendered and set(rendered) <= _CIDR_CHARS: + prefix = "IP-CIDR6" if ":" in rendered else "IP-CIDR" + rendered = f"{prefix},{rendered}" + else: + rendered = f"DOMAIN-SUFFIX,{rendered}" + parts = [part.strip() for part in rendered.split(",")] + if parts and parts[-1] == "no-resolve": + parts.insert(len(parts) - 1, policy) + return ",".join(parts) + return f"{rendered},{policy}" + + +def build_conf_rule_lines(plan: ConfRuntimePlan, *, base_path: Path) -> list[str]: + config_dir = base_path.resolve().parent + lines: list[str] = [] + final_lines: list[str] = [] + + for rule in plan.prepend_rules: + if rule.startswith("MATCH,"): + final_lines.append(rule) + else: + lines.append(rule) + + for module in plan.selected_modules: + module_path = (config_dir / module.path).resolve() + if not module_path.is_file(): + raise FileNotFoundError(f"Rule file missing: {module.path}") + for payload_line in load_rule_payload(module_path): + rendered = _render_rule_line(payload_line, module.policy) + if rendered: + if rendered.startswith("MATCH,"): + final_lines.append(rendered) + else: + lines.append(rendered) + + for builtin in plan.selected_builtins: + if builtin.rule_line.startswith("MATCH,"): + final_lines.append(builtin.rule_line) + else: + lines.append(builtin.rule_line) + + for rule in plan.append_rules: + if rule.startswith("MATCH,"): + final_lines.append(rule) + else: + lines.append(rule) + + return [*lines, *final_lines] + + +def resolve_conf_runtime_plan( + *, + profile_key: str, + base_config: ConfBaseConfig, + profile_config: ConfProfileConfig, + base_path: Path, +) -> ConfRuntimePlan: + if not profile_config.enabled: + raise ConfConfigError(base_path, 1, f"profile is disabled: {profile_key}") + + selectors = {item.key: item.regex for item in base_config.selectors} + source_map = {item.key: item for item in base_config.sources} + module_map = {item.key: item for item in base_config.modules} + builtin_map = {item.key: item for item in base_config.builtins} + override_map = {item.module_key: item.policy for item in profile_config.override_policies} + + selected_source_keys = profile_config.sources or [item.key for item in base_config.sources if item.enabled] + selected_sources = [ + ConfResolvedSource( + key=source_map[key].key, + display_name=str(source_map[key].options.get("display_name", source_map[key].key)).strip() or source_map[key].key, + source_type=source_map[key].source_type, + value=source_map[key].value, + enabled=source_map[key].enabled, + cache_ttl=source_map[key].cache_ttl, + options=dict(source_map[key].options), + ) + for key in selected_source_keys + if source_map[key].enabled + ] + + excluded_modules = set(profile_config.exclude_modules) + include_keys = profile_config.include_modules or [item.key for item in base_config.modules if item.enabled] + selected_modules: list[ConfResolvedModule] = [] + for key in include_keys: + module = module_map[key] + if not module.enabled or key in excluded_modules: + continue + selected_modules.append( + ConfResolvedModule( + key=module.key, + path=module.path, + policy=override_map.get(module.key, module.policy), + order=module.order, + enabled=module.enabled, + ) + ) + selected_modules.sort(key=lambda item: (item.order, item.key)) + + builtin_keys = profile_config.include_builtins or [item.key for item in base_config.builtins if item.enabled] + selected_builtins: list[ConfResolvedBuiltin] = [] + for key in builtin_keys: + builtin = builtin_map[key] + if not builtin.enabled: + continue + selected_builtins.append( + ConfResolvedBuiltin( + key=builtin.key, + rule_line=_resolve_builtin_rule_line(builtin.builtin_type, builtin.value, builtin.policy), + policy=builtin.policy, + order=builtin.order, + enabled=builtin.enabled, + ) + ) + selected_builtins.sort(key=lambda item: (item.order, item.key)) + + known_group_names = {group.name for group in base_config.groups} + resolved_groups = [ + _parse_group(group, selectors=selectors, known_group_names=known_group_names, path=base_path) + for group in base_config.groups + ] + + return ConfRuntimePlan( + profile_key=profile_key, + profile_name=profile_config.name, + selected_sources=selected_sources, + selected_modules=selected_modules, + selected_builtins=selected_builtins, + selectors=selectors, + groups=resolved_groups, + prepend_rules=[item.value for item in profile_config.prepend_rules], + append_rules=[item.value for item in profile_config.append_rules], + mode=base_config.mode, + allow_lan=base_config.allow_lan, + log_level=base_config.log_level, + ipv6=base_config.ipv6, + append_userinfo_header=base_config.append_userinfo_header, + userinfo_source_policy=base_config.userinfo_source_policy, + ) diff --git a/config/conf/base.conf b/config/conf/base.conf index 60bfd45..dc67573 100644 --- a/config/conf/base.conf +++ b/config/conf/base.conf @@ -8,22 +8,75 @@ ipv6 = true append_userinfo_header = true userinfo_source_policy = first_enabled_source -source = airport-a,url,${AIRPORT_A_URL},enabled=true,cache_ttl=1800 -source = airport-b,url,${AIRPORT_B_URL},enabled=true,cache_ttl=1800 +source = airport-a,url,${AIRPORT_A_URL},enabled=true,cache_ttl=1800,display_name=A,prefix=[A] +source = airport-b,url,${AIRPORT_B_URL},enabled=true,cache_ttl=1800,display_name=B,prefix=[B] +source = airport-c,url,${AIRPORT_C_URL},enabled=false,cache_ttl=1800,display_name=C,prefix=[C] selector = all,.* selector = hk,(?i)(港|hk|hong kong|hongkong) +selector = tw,(?i)(台|新北|彰化|tw|taiwan) +selector = sg,(?i)(新加坡|坡|狮城|sg|singapore) +selector = jp,(?i)(日本|东京|大阪|埼玉|jp|japan) selector = us,(?i)(美|us|united states) +selector = kr,(?i)(kr|korea|kor|首尔|韩|韓) +selector = netflix,(?i)(nf|奈飞|解锁|netflix|media) -group = 🚀 节点选择`select`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇺🇲 美国节点`[]DIRECT group = ♻️ 自动选择`url-test`@all`https://www.gstatic.com/generate_204`300,,50 +group = ☁️ 机场选择`select`[]♻️ 自动选择`@source_auto_groups`[]DIRECT +group = 🚀 手动切换`select`@all_proxies`[]DIRECT +group = 🚀 节点选择`select`[]☁️ 机场选择`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🎥 奈飞节点`[]🚀 手动切换`[]DIRECT +group = 📲 电报消息`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇸🇬 狮城节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT +group = 💬 Ai平台`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇺🇲 美国节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT +group = 📹 油管视频`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇸🇬 狮城节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT +group = 🎥 奈飞视频`select`[]🎥 奈飞节点`[]🚀 节点选择`[]♻️ 自动选择`[]🇸🇬 狮城节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT +group = 🌍 国外媒体`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT +group = 📢 谷歌`select`[]🚀 节点选择`[]DIRECT`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = Ⓜ️ 微软Bing`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = Ⓜ️ 微软云盘`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = Ⓜ️ 微软服务`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = 🍎 苹果服务`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = 🎮 游戏平台`select`[]🚀 节点选择`[]DIRECT`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = 🎮 PT平台`select`[]🚀 节点选择`[]DIRECT`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换 +group = 🎯 全球直连`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择 +group = 🛑 广告拦截`select`[]REJECT`[]DIRECT +group = 🍃 应用净化`select`[]REJECT`[]DIRECT +group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🎥 奈飞节点`[]🚀 手动切换 group = 🇭🇰 香港节点`url-test`@hk`https://www.gstatic.com/generate_204`300,,50 +group = 🇨🇳 台湾节点`url-test`@tw`https://www.gstatic.com/generate_204`300,,50 +group = 🇸🇬 狮城节点`url-test`@sg`https://www.gstatic.com/generate_204`300,,50 +group = 🇯🇵 日本节点`url-test`@jp`https://www.gstatic.com/generate_204`300,,50 group = 🇺🇲 美国节点`url-test`@us`https://www.gstatic.com/generate_204`300,,150 -group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择 +group = 🇰🇷 韩国节点`url-test`@kr`https://www.gstatic.com/generate_204`300,,50 +group = 🎥 奈飞节点`select`@netflix +module = custom-proxy,../rules/modules/custom-proxy.list,🚀 节点选择,1,true module = local-network,../rules/acl4ssr/LocalAreaNetwork.yaml,🎯 全球直连,10,true +module = unban,../rules/acl4ssr/UnBan.yaml,🎯 全球直连,20,true +module = reject,../rules/acl4ssr/BanAD.yaml,🛑 广告拦截,40,true +module = app-purify,../rules/acl4ssr/BanProgramAD.yaml,🍃 应用净化,50,true +module = google,../rules/acl4ssr/Google.yaml,📢 谷歌,60,true +module = google-cn,../rules/acl4ssr/GoogleCN.yaml,🎯 全球直连,61,true +module = steam-cn,../rules/acl4ssr/SteamCN.yaml,🎯 全球直连,62,true +module = microsoft-bing,../rules/acl4ssr/Bing.yaml,Ⓜ️ 微软Bing,70,true +module = microsoft-onedrive,../rules/acl4ssr/OneDrive.yaml,Ⓜ️ 微软云盘,71,true +module = microsoft,../rules/acl4ssr/Microsoft.yaml,Ⓜ️ 微软服务,72,true module = apple,../rules/acl4ssr/Apple.yaml,🍎 苹果服务,80,true +module = telegram,../rules/acl4ssr/Telegram.yaml,📲 电报消息,90,true +module = ai,../rules/acl4ssr/AI.yaml,💬 Ai平台,99,true module = openai,../rules/acl4ssr/OpenAi.yaml,💬 Ai平台,100,true +module = youtube,../rules/acl4ssr/YouTube.yaml,📹 油管视频,110,true +module = netflix,../rules/acl4ssr/Netflix.yaml,🎥 奈飞视频,111,true +module = proxy-media,../rules/acl4ssr/ProxyMedia.yaml,🌍 国外媒体,112,true +module = games-epic,../rules/acl4ssr/Epic.yaml,🎮 游戏平台,120,true +module = games-origin,../rules/acl4ssr/Origin.yaml,🎮 游戏平台,121,true +module = games-sony,../rules/acl4ssr/Sony.yaml,🎮 游戏平台,122,true +module = games-steam,../rules/acl4ssr/Steam.yaml,🎮 游戏平台,123,true +module = games-nintendo,../rules/acl4ssr/Nintendo.yaml,🎮 游戏平台,124,true +module = pt,../rules/acl4ssr/PrivateTracker.yaml,🎮 PT平台,125,true +module = cn-domain,../rules/acl4ssr/ChinaDomain.yaml,🎯 全球直连,130,true +module = cn-company-ip,../rules/acl4ssr/ChinaCompanyIp.yaml,🎯 全球直连,131,true +module = download,../rules/acl4ssr/Download.yaml,🎯 全球直连,132,true +module = proxy-gfw,../rules/acl4ssr/ProxyGFWlist.yaml,🚀 节点选择,140,true builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true -builtin = final,FINAL,,🐟 漏网之鱼,9999,true +builtin = final,FINAL,,🚀 节点选择,9999,true diff --git a/config/conf/profiles/default.conf b/config/conf/profiles/default.conf new file mode 100644 index 0000000..e4760a1 --- /dev/null +++ b/config/conf/profiles/default.conf @@ -0,0 +1,7 @@ +name = 默认完整配置 +enabled = true + +sources = airport-a,airport-b +include_modules = custom-proxy,local-network,unban,reject,app-purify,google,google-cn,steam-cn,microsoft-bing,microsoft-onedrive,microsoft,apple,telegram,ai,openai,youtube,netflix,proxy-media,games-epic,games-origin,games-sony,games-steam,games-nintendo,pt,cn-domain,cn-company-ip,download,proxy-gfw +exclude_modules = +include_builtins = geoip_cn,final diff --git a/config/conf/profiles/lite.conf b/config/conf/profiles/lite.conf new file mode 100644 index 0000000..19524f1 --- /dev/null +++ b/config/conf/profiles/lite.conf @@ -0,0 +1,9 @@ +name = 精简配置 +enabled = true + +sources = airport-a +include_modules = openai +exclude_modules = +include_builtins = final + +prepend_rule = DOMAIN-SUFFIX,lite.example.com,DIRECT diff --git a/config/rules/modules/custom-proxy.list b/config/rules/modules/custom-proxy.list new file mode 100644 index 0000000..6f1e715 --- /dev/null +++ b/config/rules/modules/custom-proxy.list @@ -0,0 +1,8 @@ +DOMAIN-KEYWORD,cloudflare +DOMAIN-KEYWORD,hetzner +DOMAIN-KEYWORD,hdkylin +DOMAIN-KEYWORD,steamusercontent +DOMAIN-KEYWORD,steamcontent +DOMAIN-KEYWORD,nintendo +DOMAIN-KEYWORD,vscode +DOMAIN-KEYWORD,btschool diff --git a/scripts/compare_conf_outputs.py b/scripts/compare_conf_outputs.py new file mode 100644 index 0000000..ae2949b --- /dev/null +++ b/scripts/compare_conf_outputs.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +import yaml +from fastapi.testclient import TestClient + +ROOT_DIR = Path(__file__).resolve().parent.parent +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + + +def _write_sample_source(path: Path) -> None: + path.write_text( + "\n".join( + [ + "proxies:", + " - name: hk-demo", + " type: ss", + " server: 1.1.1.1", + " port: 443", + " cipher: aes-128-gcm", + " password: demo-pass", + " - name: us-demo", + " type: ss", + " server: 2.2.2.2", + " port: 443", + " cipher: aes-128-gcm", + " password: demo-pass", + ] + ), + encoding="utf-8", + ) + + +def _load_yaml(text: str) -> dict[str, Any]: + data = yaml.safe_load(text) + if not isinstance(data, dict): + raise ValueError("response is not a yaml mapping") + return data + + +def _list_names(items: list[dict[str, Any]] | None) -> list[str]: + names: list[str] = [] + for item in items or []: + if not isinstance(item, dict): + continue + name = item.get("name") + if isinstance(name, str) and name.strip(): + names.append(name) + return names + + +def _mapping_keys(mapping: dict[str, Any] | None) -> list[str]: + if not isinstance(mapping, dict): + return [] + return sorted(mapping.keys()) + + +def _group_map(groups: list[dict[str, Any]] | None) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for group in groups or []: + if not isinstance(group, dict): + continue + name = group.get("name") + if isinstance(name, str) and name.strip(): + result[name] = group + return result + + +def _extract_summary(data: dict[str, Any]) -> dict[str, Any]: + return { + "proxy_provider_keys": _mapping_keys(data.get("proxy-providers")), + "rule_provider_keys": _mapping_keys(data.get("rule-providers")), + "proxy_group_names": _list_names(data.get("proxy-groups")), + "proxy_names": _list_names(data.get("proxies")), + "rules_count": len(data.get("rules") or []), + "rules_head": list(data.get("rules") or [])[:5], + "rules_tail": list(data.get("rules") or [])[-5:], + } + + +def _compare_group_members(legacy_groups: list[dict[str, Any]], conf_groups: list[dict[str, Any]]) -> list[dict[str, Any]]: + mismatches: list[dict[str, Any]] = [] + legacy_map = _group_map(legacy_groups) + conf_map = _group_map(conf_groups) + for name in sorted(set(legacy_map) & set(conf_map)): + legacy = legacy_map[name] + conf = conf_map[name] + legacy_members = legacy.get("proxies") or legacy.get("use") or [] + conf_members = conf.get("proxies") or conf.get("use") or [] + if legacy_members != conf_members: + mismatches.append( + { + "group": name, + "legacy_members": legacy_members, + "conf_members": conf_members, + } + ) + return mismatches + + +def compare_outputs(client_type: str, profile_key: str, sources: str, *, sample_source: Path | None) -> dict[str, Any]: + if sample_source is not None: + os.environ["AIRPORT_A_URL"] = str(sample_source) + os.environ["AIRPORT_B_URL"] = str(sample_source) + os.environ["AIRPORT_C_URL"] = str(sample_source) + + from app.main import PUBLIC_PREFIX, app + + client = TestClient(app) + legacy_thin = client.get(f"{PUBLIC_PREFIX}/clients/{client_type}.yaml?sources={sources}") + legacy_bundle = client.get(f"{PUBLIC_PREFIX}/bundle/{client_type}.yaml?sources={sources}&force_refresh=true") + conf_thin = client.get(f"{PUBLIC_PREFIX}/conf/clients/{client_type}/{profile_key}.yaml") + conf_bundle = client.get(f"{PUBLIC_PREFIX}/conf/bundle/{client_type}/{profile_key}.yaml?force_refresh=true") + + responses = { + "legacy_thin": legacy_thin, + "legacy_bundle": legacy_bundle, + "conf_thin": conf_thin, + "conf_bundle": conf_bundle, + } + for name, response in responses.items(): + if response.status_code != 200: + raise RuntimeError(f"{name} returned {response.status_code}: {response.text}") + + legacy_thin_yaml = _load_yaml(legacy_thin.text) + legacy_bundle_yaml = _load_yaml(legacy_bundle.text) + conf_thin_yaml = _load_yaml(conf_thin.text) + conf_bundle_yaml = _load_yaml(conf_bundle.text) + + result = { + "inputs": { + "client_type": client_type, + "profile_key": profile_key, + "sources": sources, + }, + "thin": { + "legacy": _extract_summary(legacy_thin_yaml), + "conf": _extract_summary(conf_thin_yaml), + "group_member_mismatches": _compare_group_members( + legacy_thin_yaml.get("proxy-groups") or [], + conf_thin_yaml.get("proxy-groups") or [], + ), + }, + "bundle": { + "legacy": _extract_summary(legacy_bundle_yaml), + "conf": _extract_summary(conf_bundle_yaml), + "group_member_mismatches": _compare_group_members( + legacy_bundle_yaml.get("proxy-groups") or [], + conf_bundle_yaml.get("proxy-groups") or [], + ), + }, + } + result["thin"]["proxy_provider_match"] = result["thin"]["legacy"]["proxy_provider_keys"] == result["thin"]["conf"]["proxy_provider_keys"] + result["thin"]["rule_provider_match"] = result["thin"]["legacy"]["rule_provider_keys"] == result["thin"]["conf"]["rule_provider_keys"] + result["thin"]["group_name_match"] = result["thin"]["legacy"]["proxy_group_names"] == result["thin"]["conf"]["proxy_group_names"] + result["bundle"]["group_name_match"] = result["bundle"]["legacy"]["proxy_group_names"] == result["bundle"]["conf"]["proxy_group_names"] + result["bundle"]["proxy_count_match"] = len(result["bundle"]["legacy"]["proxy_names"]) == len(result["bundle"]["conf"]["proxy_names"]) + result["bundle"]["rules_count_match"] = result["bundle"]["legacy"]["rules_count"] == result["bundle"]["conf"]["rules_count"] + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Compare legacy YAML routes with conf routes") + parser.add_argument("--client-type", default="mihomo") + parser.add_argument("--profile", default="default") + parser.add_argument("--sources", default="airport-a,airport-b") + parser.add_argument("--sample-source", default="") + args = parser.parse_args() + + if args.sample_source: + sample_source = Path(args.sample_source).resolve() + else: + sample_source = None + + if sample_source is None: + with TemporaryDirectory() as tmp: + generated = Path(tmp) / "sample-proxies.yaml" + _write_sample_source(generated) + result = compare_outputs(args.client_type, args.profile, args.sources, sample_source=generated) + else: + result = compare_outputs(args.client_type, args.profile, args.sources, sample_source=sample_source) + + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_conf_parity.py b/tests/test_conf_parity.py new file mode 100644 index 0000000..2ee34b8 --- /dev/null +++ b/tests/test_conf_parity.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +ROOT_DIR = Path(__file__).resolve().parent.parent +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + + +def _write_sample_source(path: Path) -> None: + path.write_text( + "\n".join( + [ + "proxies:", + " - name: hk-demo", + " type: ss", + " server: 1.1.1.1", + " port: 443", + " cipher: aes-128-gcm", + " password: demo-pass", + " - name: us-demo", + " type: ss", + " server: 2.2.2.2", + " port: 443", + " cipher: aes-128-gcm", + " password: demo-pass", + ] + ), + encoding="utf-8", + ) + + +class ConfParityTest(unittest.TestCase): + def test_compare_script_core_matches(self) -> None: + with TemporaryDirectory() as tmp: + sample = Path(tmp) / "sample.yaml" + _write_sample_source(sample) + os.environ["AIRPORT_A_URL"] = str(sample) + os.environ["AIRPORT_B_URL"] = str(sample) + os.environ["AIRPORT_C_URL"] = str(sample) + + from scripts.compare_conf_outputs import compare_outputs + + result = compare_outputs("mihomo", "default", "airport-a,airport-b", sample_source=sample) + + self.assertTrue(result["thin"]["proxy_provider_match"]) + self.assertTrue(result["thin"]["rule_provider_match"]) + self.assertTrue(result["thin"]["group_name_match"]) + self.assertTrue(result["bundle"]["group_name_match"]) + self.assertTrue(result["bundle"]["proxy_count_match"]) + self.assertTrue(result["bundle"]["rules_count_match"]) + self.assertEqual(result["thin"]["legacy"]["rules_head"], result["thin"]["conf"]["rules_head"]) + self.assertEqual(result["bundle"]["legacy"]["rules_tail"], result["bundle"]["conf"]["rules_tail"]) + + +if __name__ == "__main__": + unittest.main()