diff --git a/app/expander.py b/app/expander.py new file mode 100644 index 0000000..6e1e101 --- /dev/null +++ b/app/expander.py @@ -0,0 +1,63 @@ +import yaml + +PROXY_FIELDS = { + "name", "type", "server", "port", "uuid", "password", "cipher", + "alterId", "tls", "network", "ws-opts", "h2-opts", "http-opts", + "grpc-opts", "reality-opts", "servername", "fingerprint", "flow", + "udp", "skip-cert-verify", "sni", "username", "plugin", "plugin-opts", + "client-fingerprint", "early-data-header-name", "smux", +} + + +def filter_proxy(proxy: dict) -> dict: + return {k: v for k, v in proxy.items() if k in PROXY_FIELDS} + + +def expand_config(base_yaml: str, provider_proxies: dict[str, list[dict]]) -> str: + cfg = yaml.safe_load(base_yaml) + + all_proxies = list(cfg.get("proxies") or []) + provider_to_names: dict[str, list[str]] = {} + + for provider_name, proxies in provider_proxies.items(): + filtered = [filter_proxy(p) for p in proxies] + provider_to_names[provider_name] = [p["name"] for p in filtered] + all_proxies.extend(filtered) + + cfg["proxies"] = all_proxies + + for group in cfg.get("proxy-groups") or []: + if "use" in group: + expanded: list[str] = [] + for pname in group.pop("use"): + expanded.extend(provider_to_names.get(pname, [])) + group.setdefault("proxies", []) + group["proxies"] = group["proxies"] + expanded + + cfg.pop("proxy-providers", None) + + return yaml.dump(cfg, allow_unicode=True, sort_keys=False) + + +def build_mihomo_config(all_base_yamls: list[str], secret: str) -> str: + providers: dict[str, dict] = {} + + for base_yaml in all_base_yamls: + try: + cfg = yaml.safe_load(base_yaml) or {} + for name, provider in (cfg.get("proxy-providers") or {}).items(): + if name not in providers: + providers[name] = provider + except yaml.YAMLError: + continue + + config: dict = { + "external-controller": "0.0.0.0:9090", + "secret": secret, + "mixed-port": 7890, + "allow-lan": False, + } + if providers: + config["proxy-providers"] = providers + + return yaml.dump(config, allow_unicode=True, sort_keys=False) diff --git a/app/tests/test_expander.py b/app/tests/test_expander.py new file mode 100644 index 0000000..5369bb6 --- /dev/null +++ b/app/tests/test_expander.py @@ -0,0 +1,151 @@ +import yaml +from expander import filter_proxy, expand_config, build_mihomo_config + +PROXY_FULL = { + "name": "node1", + "type": "ss", + "server": "1.2.3.4", + "port": 443, + "password": "secret", + "cipher": "aes-256-gcm", + "alive": True, # runtime field — should be stripped + "history": [], # runtime field — should be stripped + "extra": {"foo": "bar"}, # runtime field — should be stripped +} + +BASE_YAML = """ +proxies: [] +proxy-providers: + provider1: + type: http + url: https://example.com/sub + interval: 3600 +proxy-groups: + - name: Proxy + type: select + use: + - provider1 +rules: + - MATCH,DIRECT +""" + + +def test_filter_proxy_removes_runtime_fields(): + filtered = filter_proxy(PROXY_FULL) + assert "alive" not in filtered + assert "history" not in filtered + assert "extra" not in filtered + assert filtered["name"] == "node1" + assert filtered["server"] == "1.2.3.4" + + +def test_expand_config_replaces_providers_with_proxies(): + proxies = [ + {"name": "node1", "type": "ss", "server": "1.2.3.4", "port": 443, + "password": "pwd", "cipher": "aes-256-gcm"}, + ] + result = expand_config(BASE_YAML, {"provider1": proxies}) + cfg = yaml.safe_load(result) + + assert "proxy-providers" not in cfg + assert any(p["name"] == "node1" for p in cfg["proxies"]) + + +def test_expand_config_fills_use_in_proxy_groups(): + proxies = [ + {"name": "node1", "type": "ss", "server": "1.2.3.4", "port": 443, + "password": "pwd", "cipher": "aes-256-gcm"}, + {"name": "node2", "type": "ss", "server": "5.6.7.8", "port": 443, + "password": "pwd", "cipher": "aes-256-gcm"}, + ] + result = expand_config(BASE_YAML, {"provider1": proxies}) + cfg = yaml.safe_load(result) + + proxy_group = next(g for g in cfg["proxy-groups"] if g["name"] == "Proxy") + assert "use" not in proxy_group + assert "node1" in proxy_group["proxies"] + assert "node2" in proxy_group["proxies"] + + +def test_expand_config_preserves_existing_proxies(): + base = """ +proxies: + - name: static-node + type: direct +proxy-providers: + p1: + type: http + url: https://example.com/sub + interval: 3600 +proxy-groups: [] +rules: [] +""" + result = expand_config(base, {"p1": [{"name": "dynamic-node", "type": "ss", + "server": "1.2.3.4", "port": 443}]}) + cfg = yaml.safe_load(result) + names = [p["name"] for p in cfg["proxies"]] + assert "static-node" in names + assert "dynamic-node" in names + + +def test_expand_config_empty_providers(): + result = expand_config(BASE_YAML, {}) + cfg = yaml.safe_load(result) + assert "proxy-providers" not in cfg + + +def test_build_mihomo_config_merges_providers(): + yaml1 = """ +proxy-providers: + p1: + type: http + url: https://a.example.com/sub + interval: 3600 + health-check: + enable: true + url: http://www.gstatic.com/generate_204 + interval: 300 +""" + yaml2 = """ +proxy-providers: + p2: + type: http + url: https://b.example.com/sub + interval: 3600 +""" + result = build_mihomo_config([yaml1, yaml2], "mysecret") + cfg = yaml.safe_load(result) + + assert cfg["external-controller"] == "0.0.0.0:9090" + assert cfg["secret"] == "mysecret" + assert cfg["mixed-port"] == 7890 + assert cfg["allow-lan"] is False + assert "p1" in cfg["proxy-providers"] + assert "p2" in cfg["proxy-providers"] + assert cfg["proxy-providers"]["p1"]["health-check"]["enable"] is True + + +def test_build_mihomo_config_deduplicates_by_name(): + yaml1 = """ +proxy-providers: + p1: + type: http + url: https://original.example.com/sub + interval: 3600 +""" + yaml2 = """ +proxy-providers: + p1: + type: http + url: https://duplicate.example.com/sub + interval: 3600 +""" + result = build_mihomo_config([yaml1, yaml2], "s") + cfg = yaml.safe_load(result) + assert cfg["proxy-providers"]["p1"]["url"] == "https://original.example.com/sub" + + +def test_build_mihomo_config_no_providers(): + result = build_mihomo_config(["proxies: []"], "s") + cfg = yaml.safe_load(result) + assert "proxy-providers" not in cfg