from typing import Any, Literal from pydantic import BaseModel, Field, model_validator, field_validator, field_serializer, PrivateAttr from app.log import logger from .proxy import Proxy from .proxygroups import ProxyGroup from .proxyproviders import ProxyProvider from .proxy.tlsmixin import ClientFingerprint from .ruleproviders import RuleProvider from .rule import RuleType, Action, RoutingRuleType from ..helper.clashruleparser import ClashRuleParser class ExternalControllerCors(BaseModel): allow_origins: list[str] = Field(default_factory=lambda: ["*"], alias="allow-origins") allow_credentials: bool = Field(default=True, alias="allow-credentials") class Profile(BaseModel): store_selected: bool = Field(default=False, alias="store-selected") store_fake_ip: bool = Field(default=False, alias="store-fake-ip") class NTP(BaseModel): enable: bool = Field(default=False) Server: str = Field(default="time.apple.com") port: int = Field(default=123) write_to_system: bool = Field(default=False, alias="write-to-system") class Experimental(BaseModel): quic_go_disable_gso: bool = Field(default=False, alias="quic-go-disable-gso") quic_go_disable_ecn: bool = Field(default=True, alias="quic-go-disable-ecn") dialer_ip4p_convert: bool = Field(default=False, alias="dialer-ip4p-convert") class ClashConfig(BaseModel): _raw_proxies: dict[str, str] = PrivateAttr(default_factory=dict) dns: dict[str, Any] | None = Field(default=None) hosts: dict[str, list[str] | str] | None = Field(default=None) allow_lan: bool | None = Field(default=None, alias="allow-lan") bind_address: str = Field(default="*", alias="bind-address") lan_allowed_ips: list[str] = Field(default_factory=lambda: ["0.0.0.0/0", "::/0"], alias="lan-allowed-ips") lan_disallowed_ips: list[str] = Field(default_factory=list, alias="lan-disallowed-ips") authentication: list[str] = Field(default_factory=list) skip_auth_prefixes: list[str] = Field(default_factory=list, alias="skip-auth-prefixes") mode: Literal["rule", "global", "direct"] = Field(default="rule") log_level: Literal["silent", "error", "warning", "info", "debug"] = Field(default="info", alias="log-level") ipv6: bool = Field(default=True) keep_alive_interval: int = Field(default=0, alias="keep-alive-interval") keep_alive_idle: int = Field(default=0, alias="keep-alive-idle") disable_keep_alive: bool = Field(default=False, alias="disable-keep-alive") find_process_mode: Literal["strict", "always", "off"] = Field(default="strict", alias="find-process-mode") external_controller: str | None = Field(default=None, alias="external-controller") external_controller_cors: ExternalControllerCors = Field(default_factory=ExternalControllerCors, alias="external-controller-cors") external_controller_unix: str | None = Field(default=None, alias="external-controller-unix") external_controller_pipe: str | None = Field(default=None, alias="external-controller-pipe") external_controller_tls: str | None = Field(default=None, alias="external-controller-tls") secret: str | None = Field(default=None) external_ui: str | None = Field(default=None, alias="external-ui") external_ui_name: str | None = Field(default=None, alias="external-ui-name") external_ui_url: str | None = Field(default=None, alias="external-ui-url") profile: Profile = Field(default_factory=Profile) unified_delay: bool = Field(default=True, alias="unified-delay") tcp_concurrent: bool = Field(default=True, alias="tcp-concurrent") interface_name: str | None = Field(default=None, alias="interface-name") routing_mark: int | None = Field(default=None, alias="routing-mark") tls: dict[str, Any] | None = Field(default=None, alias="tls") global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome, alias="global-client-fingerprint") geodata_mode: bool | None = Field(default=None, alias="geodata-mode") geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader") geo_auto_update: bool = Field(default=False, alias="geo-auto-update") geo_update_interval: int = Field(default=24, alias="geo-update-interval") global_ua: str = Field(default="clash.meta", alias="global-ua") etag_support: bool = Field(default=True, alias="etag-support") sniffer: dict[str, Any] | None = None listeners: list[dict[str, Any]] | None = Field(default=None) port: int = Field(default=0, description="HTTP(S) proxy port") socks_port: int = Field(default=0, alias="socks-port") mixed_port: int = Field(default=0, alias="mixed-port") redir_port: int = Field(default=0, alias="redir-port") tproxy_port: int = Field(default=0, alias="tproxy-port") tun: dict[str, Any] | None = Field(default=None) sub_rules: dict[str, Any] | None = Field(default=None, alias="sub-rules") tunnels: list[dict[str, Any] | str] | None = Field(default=None) ntp: NTP | None = Field(default=None) experimental: Experimental | None = Field(default=None) proxies: list[Proxy] = Field(default_factory=list) proxy_providers: dict[str, ProxyProvider] = Field(default_factory=dict, alias="proxy-providers") proxy_groups: list[ProxyGroup] = Field(default_factory=list, alias="proxy-groups") rules: list[RuleType] = Field(default_factory=list) rule_providers: dict[str, RuleProvider] = Field(default_factory=dict, alias="rule-providers") @model_validator(mode="before") @classmethod def fill_none_with_default(cls, values: dict): fill_none_fields = {"proxies", "proxy_providers", "proxy_groups", "rules", "rule_providers"} for field_name in fill_none_fields: field = cls.model_fields[field_name] factory = field.default_factory if not factory: continue keys = {field_name} if field.alias: keys.add(field.alias) for key in keys: if key in values and values[key] is None: values[key] = factory() return values @field_serializer("proxies") def serialize_proxies(self, v: list[Proxy], _info): serialized_proxies = [] seen_names = set() for proxy in v: if proxy.name in seen_names: logger.warning(f"Skipping duplicate proxy: {proxy.name}") continue seen_names.add(proxy.name) serialized_proxies.append(proxy.model_dump(by_alias=True, exclude_none=True, mode="json")) return serialized_proxies @field_serializer("proxy_groups") def serialize_proxy_groups(self, v: list[ProxyGroup], _info): valid_outbounds = {a.value for a in Action} valid_outbounds.add("GLOBAL") if self.proxies: valid_outbounds.update(p.name for p in self.proxies) if v: valid_outbounds.update(pg.name for pg in v) serialized_groups = [] seen_names = set() for group in v: if group.name in seen_names: logger.warning(f"Skipping duplicate proxy group: {group.name}") continue seen_names.add(group.name) group_data = group.model_dump(by_alias=True, exclude_none=True, mode="json") if "proxies" in group_data and group_data["proxies"]: original_proxies = group_data["proxies"] group_data["proxies"] = [ p for p in original_proxies if p in valid_outbounds ] removed = set(original_proxies) - set(group_data["proxies"]) if removed: logger.warning(f"Proxy group {group.name} removed missing proxies: {removed}") serialized_groups.append(group_data) return serialized_groups @field_validator("mode", mode="before") @classmethod def validate_mode(cls, v): if isinstance(v, str): return v.lower() return v @field_validator("rules", mode="before") @classmethod def validate_rules(cls, v): if isinstance(v, list): rules = [] for item in v: if isinstance(item, str): rules.append(ClashRuleParser.parse(item)) else: rules.append(item) return rules return v @field_serializer("rules") def serialize_rules(self, v: list[RuleType], _info): valid_rules = [] valid_outbounds = set(self.outbounds) valid_actions = {a.value for a in Action} for rule in v: if rule.rule_type == RoutingRuleType.SUB_RULE: if self.sub_rules and rule.action in self.sub_rules: valid_rules.append(rule) else: logger.warning(f"Skipping rule with missing sub-rule action: {rule}") continue if rule.rule_type == RoutingRuleType.RULE_SET: if rule.payload not in self.rule_providers: logger.warning(f"Skipping rule with missing rule-provider: {rule}") continue action_str = str(rule.action) if action_str in valid_actions or action_str in valid_outbounds: valid_rules.append(rule) else: logger.warning(f"Skipping rule with invalid outbound: {rule}") return [str(rule) for rule in valid_rules] @property def outbounds(self) -> list[str]: outbounds = [] if self.proxies: outbounds.extend(p.name for p in self.proxies) if self.proxy_groups: outbounds.extend(pg.name for pg in self.proxy_groups) return outbounds @property def node_num(self) -> int: return len(self.proxies) @property def raw_proxies(self) -> dict[str, str]: return self._raw_proxies @raw_proxies.setter def raw_proxies(self, value: dict[str, str]): self._raw_proxies = value def merge(self, other: 'ClashConfig') -> 'ClashConfig': self.proxies += other.proxies self.proxy_groups += other.proxy_groups self.rules += other.rules self.rule_providers |= other.rule_providers self.proxy_providers |= other.proxy_providers return self