2e1c488bb9
- Fetch subscriptions directly via httpx (mihomo UA → YAML, xray-checker → base64 URI list) - Add uri_parser.py: vless/vmess/ss/trojan/hysteria2 URI → Mihomo proxy dicts - Fix YAML quoting for Go parser (strings starting with special chars) - Remove hidden:true and proxy-providers from delivered configs - Inject all service groups into GLOBAL proxies for FLClashX group discovery - Strip placeholder proxy-providers (e.g. "subscription") not in DB - Fix Mihomo healthcheck: add Bearer auth header - Add README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
183 lines
6.4 KiB
Python
183 lines
6.4 KiB
Python
import yaml
|
|
from yaml.representer import SafeRepresenter
|
|
|
|
|
|
def _str_representer(dumper: yaml.Dumper, value: str) -> yaml.ScalarNode:
|
|
# Quote strings that start with special YAML characters so Go parser doesn't choke
|
|
if value and value[0] in ('-', ':', '{', '}', '[', ']', ',', '#', '&', '*', '?', '|', '<', '>', '=', '!', '%', '@', '`'):
|
|
return dumper.represent_scalar('tag:yaml.org,2002:str', value, style="'")
|
|
return SafeRepresenter.represent_str(dumper, value)
|
|
|
|
|
|
class _SafeDumper(yaml.SafeDumper):
|
|
pass
|
|
|
|
|
|
_SafeDumper.add_representer(str, _str_representer)
|
|
|
|
|
|
def _dump(data: dict) -> str:
|
|
return yaml.dump(data, Dumper=_SafeDumper, allow_unicode=True, sort_keys=False)
|
|
|
|
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",
|
|
}
|
|
|
|
# Types that are not real proxy protocols — Mihomo may include them in provider
|
|
# responses but they're not valid standalone proxy entries.
|
|
_SKIP_TYPES = {"direct", "dns", "reject", "pass", "compatible", "relay",
|
|
"selector", "fallback", "url-test", "load-balance"}
|
|
|
|
|
|
def filter_proxy(proxy: dict) -> dict:
|
|
result = {k: v for k, v in proxy.items() if k in PROXY_FIELDS}
|
|
if "type" in result and isinstance(result["type"], str):
|
|
result["type"] = result["type"].lower()
|
|
return result
|
|
|
|
|
|
def _is_real_proxy(proxy: dict) -> bool:
|
|
return "name" in proxy and proxy.get("type", "").lower() not in _SKIP_TYPES
|
|
|
|
|
|
def expand_config(base_yaml: str, provider_proxies: dict[str, list[dict]]) -> str:
|
|
cfg = yaml.safe_load(base_yaml) or {}
|
|
|
|
all_proxies = list(cfg.get("proxies") or [])
|
|
provider_to_names: dict[str, list[str]] = {}
|
|
all_sub_names: list[str] = []
|
|
|
|
for provider_name, proxies in provider_proxies.items():
|
|
filtered = [filter_proxy(p) for p in proxies if _is_real_proxy(p)]
|
|
provider_to_names[provider_name] = [p["name"] for p in filtered]
|
|
all_proxies.extend(filtered)
|
|
all_sub_names.extend(p["name"] for p in filtered)
|
|
|
|
cfg["proxies"] = all_proxies
|
|
|
|
# Deduplicate subscription proxy names (preserve order)
|
|
seen: set[str] = set()
|
|
unique_sub_names: list[str] = []
|
|
for n in all_sub_names:
|
|
if n not in seen:
|
|
seen.add(n)
|
|
unique_sub_names.append(n)
|
|
|
|
cfg["proxy-groups"] = list(cfg.get("proxy-groups") or [])
|
|
|
|
sub_names_set = set(unique_sub_names)
|
|
expanded_group_names: list[str] = []
|
|
|
|
for group in cfg.get("proxy-groups") or []:
|
|
group.pop("hidden", None)
|
|
if "use" not in group:
|
|
continue
|
|
name = group.get("name", "")
|
|
expanded: list[str] = []
|
|
for pname in group.pop("use"):
|
|
expanded.extend(provider_to_names.get(pname, []))
|
|
|
|
original = group.get("proxies") or []
|
|
special = [p for p in original if p not in sub_names_set and p != "DIRECT"]
|
|
group["proxies"] = special + expanded + ["DIRECT"]
|
|
if not group["proxies"]:
|
|
group["proxies"] = ["DIRECT"]
|
|
|
|
if name and name != "GLOBAL":
|
|
expanded_group_names.append(name)
|
|
|
|
# Add all service groups to GLOBAL so FLClashX can discover them
|
|
if expanded_group_names:
|
|
global_group = next(
|
|
(g for g in cfg.get("proxy-groups") or [] if g.get("name") == "GLOBAL"),
|
|
None,
|
|
)
|
|
if global_group:
|
|
current = global_group.get("proxies") or []
|
|
current_set = set(current)
|
|
to_add = [n for n in expanded_group_names if n not in current_set]
|
|
global_group["proxies"] = current + to_add
|
|
|
|
cfg.pop("proxy-providers", None)
|
|
|
|
return _dump(cfg)
|
|
|
|
|
|
def inject_providers_for_delivery(base_yaml: str, subscriptions: list) -> str:
|
|
"""At delivery time: inject proxy-providers entries and use: for subscriptions absent from base_yaml."""
|
|
if not subscriptions:
|
|
return base_yaml
|
|
cfg = yaml.safe_load(base_yaml) or {}
|
|
db_sub_names = {sub.name for sub in subscriptions}
|
|
|
|
# Remove placeholder providers that aren't real DB subscriptions
|
|
if cfg.get("proxy-providers"):
|
|
for name in list(cfg["proxy-providers"].keys()):
|
|
if name not in db_sub_names:
|
|
del cfg["proxy-providers"][name]
|
|
|
|
existing_providers = set((cfg.get("proxy-providers") or {}).keys())
|
|
new_entries = {}
|
|
for sub in subscriptions:
|
|
if sub.name not in existing_providers:
|
|
new_entries[sub.name] = {
|
|
"type": "http",
|
|
"url": sub.url,
|
|
"interval": 3600,
|
|
"health-check": {
|
|
"enable": True,
|
|
"url": "https://www.gstatic.com/generate_204",
|
|
"interval": 300,
|
|
},
|
|
}
|
|
if not new_entries:
|
|
return base_yaml
|
|
all_known = existing_providers | set(new_entries.keys())
|
|
providers = cfg.setdefault("proxy-providers", {})
|
|
providers.update(new_entries)
|
|
new_names = list(new_entries.keys())
|
|
_special = {"DIRECT", "REJECT"}
|
|
for group in cfg.get("proxy-groups") or []:
|
|
if group.get("name") in _special:
|
|
continue
|
|
if "use" in group:
|
|
# Replace references to unknown providers with our subscriptions
|
|
patched = []
|
|
for u in group["use"]:
|
|
if u in all_known:
|
|
patched.append(u)
|
|
else:
|
|
patched.extend(new_names)
|
|
group["use"] = patched if patched else new_names
|
|
else:
|
|
group["use"] = new_names
|
|
return _dump(cfg)
|
|
|
|
|
|
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 _dump(config)
|