diff --git a/app/main.py b/app/main.py index 14b177f..eea1bf0 100644 --- a/app/main.py +++ b/app/main.py @@ -150,6 +150,7 @@ async def client_profile(client_type: str, request: Request, sources: str | None build_thin_profile( client_type=client_type, app_config=app_config, + client=client, selected_source_names=[name for name, _ in source_items], base_url=_base_url(request), public_path=(app_config.public_path or settings.public_path).strip("/"), @@ -198,6 +199,7 @@ async def bundle_profile( build_bundle_profile( client_type=client_type, app_config=app_config, + client=client, snapshots=snapshots, ) ) diff --git a/app/models.py b/app/models.py index aabce49..867bb7d 100644 --- a/app/models.py +++ b/app/models.py @@ -82,26 +82,6 @@ class ProviderDocument(BaseModel): proxies: list[dict[str, Any]] -class ProxyNode(BaseModel): - name: str - type: str - server: str | None = None - port: int | None = None - udp: bool = True - tags: list[str] = Field(default_factory=list) - attrs: dict[str, Any] = Field(default_factory=dict) - - def to_proxy_dict(self) -> dict[str, Any]: - data = {"name": self.name, "type": self.type} - if self.server is not None: - data["server"] = self.server - if self.port is not None: - data["port"] = self.port - data["udp"] = self.udp - data.update(self.attrs) - return data - - class SubscriptionUserInfo(BaseModel): upload: int | None = None download: int | None = None @@ -130,13 +110,3 @@ class SourceSnapshot(BaseModel): document: ProviderDocument headers: dict[str, str] = Field(default_factory=dict) quota: SubscriptionUserInfo | None = None - - -class ResolvedProfile(BaseModel): - client_type: str - client: ClientConfig - selected_sources: dict[str, SourceConfig] = Field(default_factory=dict) - rules: dict[str, RuleConfig] = Field(default_factory=dict) - regions: dict[str, RegionConfig] = Field(default_factory=dict) - selector_groups: list[ProxyGroupConfig] = Field(default_factory=list) - policy_groups: list[ProxyGroupConfig] = Field(default_factory=list) diff --git a/app/services/policy_group_builder.py b/app/services/policy_group_builder.py deleted file mode 100644 index 2351111..0000000 --- a/app/services/policy_group_builder.py +++ /dev/null @@ -1,309 +0,0 @@ -from __future__ import annotations - -import re -from typing import Any - -from app.models import ProxyGroupConfig, ResolvedProfile, SourceSnapshot -from app.services.proxy_pipeline import source_auto_group_name - - -def _expand_proxy_tokens( - proxies: list[str], - *, - resolved_profile: ResolvedProfile, - source_auto_names: list[str], - selector_names: list[str], -) -> list[str]: - client = resolved_profile.client - tokens = { - "{{ main_policy }}": [client.main_policy], - "{{main_policy}}": [client.main_policy], - "{{ source_policy }}": [client.source_policy], - "{{source_policy}}": [client.source_policy], - "{{ mixed_auto_policy }}": [client.mixed_auto_policy], - "{{mixed_auto_policy}}": [client.mixed_auto_policy], - "{{ manual_policy }}": [client.manual_policy], - "{{manual_policy}}": [client.manual_policy], - "{{ direct_policy }}": [client.direct_policy], - "{{direct_policy}}": [client.direct_policy], - "{{ source_auto_groups }}": source_auto_names, - "{{source_auto_groups}}": source_auto_names, - "{{ selector_groups }}": selector_names, - "{{selector_groups}}": selector_names, - } - expanded: list[str] = [] - for item in proxies: - expanded.extend(tokens.get(item, [item])) - return expanded - - -def _build_filter_group_for_thin( - *, - resolved_profile: ResolvedProfile, - group: ProxyGroupConfig, - selected_source_names: list[str], -) -> dict[str, Any]: - client = resolved_profile.client - built: dict[str, Any] = {"name": group.name, "type": group.type, "filter": group.filter} - if group.type == "url-test": - built["url"] = str(group.url or client.test_url) - built["interval"] = group.interval or client.test_interval - if group.tolerance is not None: - built["tolerance"] = group.tolerance - if resolved_profile.client_type == "mihomo": - built["use"] = selected_source_names - else: - built["include-all"] = True - return built - - -def _build_filter_group_for_bundle( - *, - resolved_profile: ResolvedProfile, - group: ProxyGroupConfig, - all_proxy_names: list[str], -) -> dict[str, Any]: - client = resolved_profile.client - matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)] - built: dict[str, Any] = {"name": group.name, "type": group.type, "proxies": matched or [client.direct_policy]} - if group.type == "url-test": - built["url"] = str(group.url or 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_custom_policy_groups( - *, - resolved_profile: ResolvedProfile, - source_auto_names: list[str], - selector_names: list[str], -) -> list[dict[str, Any]]: - groups: list[dict[str, Any]] = [] - client = resolved_profile.client - for group in resolved_profile.policy_groups: - built: dict[str, Any] = { - "name": group.name, - "type": group.type, - "proxies": _expand_proxy_tokens( - group.proxies, - resolved_profile=resolved_profile, - source_auto_names=source_auto_names, - selector_names=selector_names, - ), - } - if group.type == "url-test": - built["url"] = str(group.url or client.test_url) - built["interval"] = group.interval or client.test_interval - if group.tolerance is not None: - built["tolerance"] = group.tolerance - groups.append(built) - return groups - - -def build_thin_groups( - *, - resolved_profile: ResolvedProfile, - selected_source_names: list[str], -) -> list[dict[str, Any]]: - client = resolved_profile.client - groups: list[dict[str, Any]] = [] - source_auto_names: list[str] = [] - - for source_name in selected_source_names: - source = resolved_profile.selected_sources[source_name] - group_name = source_auto_group_name(source.display_name or source_name) - source_auto_names.append(group_name) - groups.append( - { - "name": group_name, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "use": [source_name], - } - ) - - if resolved_profile.client_type == "mihomo": - mixed_auto = { - "name": client.mixed_auto_policy, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "include-all-providers": True, - } - manual = { - "name": client.manual_policy, - "type": "select", - "proxies": [client.direct_policy], - "include-all-providers": True, - } - else: - mixed_auto = { - "name": client.mixed_auto_policy, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "include-all": True, - } - manual = { - "name": client.manual_policy, - "type": "select", - "proxies": [client.direct_policy], - "include-all": True, - } - - groups.append(mixed_auto) - region_names = [region.name for region in resolved_profile.regions.values()] - selector_names = [*region_names, *[selector.name for selector in resolved_profile.selector_groups]] - - groups.append({"name": client.source_policy, "type": "select", "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy]}) - groups.append(manual) - groups.append( - { - "name": client.main_policy, - "type": "select", - "proxies": [client.source_policy, client.mixed_auto_policy, *selector_names, client.manual_policy, client.direct_policy], - } - ) - groups.extend( - _build_custom_policy_groups( - resolved_profile=resolved_profile, - source_auto_names=source_auto_names, - selector_names=selector_names, - ) - ) - - for region in resolved_profile.regions.values(): - group: dict[str, Any] = { - "name": region.name, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "filter": region.filter, - "tolerance": region.tolerance, - } - if resolved_profile.client_type == "mihomo": - group["include-all-providers"] = True - else: - group["include-all"] = True - groups.append(group) - - for selector in resolved_profile.selector_groups: - if selector.filter: - groups.append( - _build_filter_group_for_thin( - resolved_profile=resolved_profile, - group=selector, - selected_source_names=selected_source_names, - ) - ) - else: - groups.append( - { - "name": selector.name, - "type": selector.type, - "proxies": _expand_proxy_tokens( - selector.proxies, - resolved_profile=resolved_profile, - source_auto_names=source_auto_names, - selector_names=selector_names, - ), - } - ) - - return groups - - -def build_bundle_groups( - *, - resolved_profile: ResolvedProfile, - snapshots: list[SourceSnapshot], - source_proxy_names: dict[str, list[str]], -) -> list[dict[str, Any]]: - client = resolved_profile.client - groups: list[dict[str, Any]] = [] - source_auto_names: list[str] = [] - all_proxy_names = [name for names in source_proxy_names.values() for name in names] - - for snapshot in snapshots: - group_name = source_auto_group_name(snapshot.display_name) - source_auto_names.append(group_name) - groups.append( - { - "name": group_name, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "proxies": source_proxy_names.get(snapshot.name) or [client.direct_policy], - } - ) - - groups.append( - { - "name": client.mixed_auto_policy, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "proxies": all_proxy_names or [client.direct_policy], - } - ) - - region_names = [region.name for region in resolved_profile.regions.values()] - selector_names = [*region_names, *[selector.name for selector in resolved_profile.selector_groups]] - - groups.append({"name": client.source_policy, "type": "select", "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy]}) - groups.append({"name": client.manual_policy, "type": "select", "proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy]}) - groups.append( - { - "name": client.main_policy, - "type": "select", - "proxies": [client.source_policy, client.mixed_auto_policy, *selector_names, client.manual_policy, client.direct_policy], - } - ) - groups.extend( - _build_custom_policy_groups( - resolved_profile=resolved_profile, - source_auto_names=source_auto_names, - selector_names=selector_names, - ) - ) - - for region in resolved_profile.regions.values(): - matched = [name for name in all_proxy_names if re.search(region.filter, name)] - groups.append( - { - "name": region.name, - "type": "url-test", - "url": str(client.test_url), - "interval": client.test_interval, - "tolerance": region.tolerance, - "proxies": matched or [client.direct_policy], - } - ) - - for selector in resolved_profile.selector_groups: - if selector.filter: - groups.append( - _build_filter_group_for_bundle( - resolved_profile=resolved_profile, - group=selector, - all_proxy_names=all_proxy_names, - ) - ) - else: - groups.append( - { - "name": selector.name, - "type": selector.type, - "proxies": _expand_proxy_tokens( - selector.proxies, - resolved_profile=resolved_profile, - source_auto_names=source_auto_names, - selector_names=selector_names, - ), - } - ) - - return groups diff --git a/app/services/profile_resolver.py b/app/services/profile_resolver.py deleted file mode 100644 index 9276adf..0000000 --- a/app/services/profile_resolver.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from app.models import AppConfig, ResolvedProfile - - -def resolve_profile( - *, - app_config: AppConfig, - client_type: str, - selected_source_names: list[str], -) -> ResolvedProfile: - client = app_config.clients[client_type] - return ResolvedProfile( - client_type=client_type, - client=client, - selected_sources={name: app_config.sources[name] for name in selected_source_names if name in app_config.sources}, - rules=dict(app_config.rules), - regions=dict(app_config.regions), - selector_groups=list(app_config.selector_groups), - policy_groups=list(app_config.policy_groups), - ) diff --git a/app/services/profiles.py b/app/services/profiles.py index e17f8e9..67acc3b 100644 --- a/app/services/profiles.py +++ b/app/services/profiles.py @@ -1,44 +1,143 @@ from __future__ import annotations +import re from typing import Any import yaml -from app.models import AppConfig, SourceSnapshot -from app.services.policy_group_builder import build_bundle_groups, build_thin_groups -from app.services.profile_resolver import resolve_profile -from app.services.proxy_pipeline import build_bundle_proxy_inventory -from app.services.rule_resolver import build_rule_provider_entries, iter_resolved_rule_lines +from app.models import AppConfig, ClientConfig, ProxyGroupConfig, SourceSnapshot +from app.services.rules import build_inline_rules, build_rule_provider_entries, build_rule_set_references def dump_yaml(data: dict[str, Any]) -> str: return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False) -def _build_profile_header(*, resolved_profile) -> dict[str, Any]: - client = resolved_profile.client - profile: dict[str, Any] = {"mode": client.mode, "ipv6": client.ipv6} +def _expand_proxy_tokens( + proxies: list[str], + *, + client: ClientConfig, + source_auto_names: list[str], + selector_names: list[str], +) -> list[str]: + expanded: list[str] = [] + tokens = { + "{{ main_policy }}": [client.main_policy], + "{{main_policy}}": [client.main_policy], + "{{ source_policy }}": [client.source_policy], + "{{source_policy}}": [client.source_policy], + "{{ mixed_auto_policy }}": [client.mixed_auto_policy], + "{{mixed_auto_policy}}": [client.mixed_auto_policy], + "{{ manual_policy }}": [client.manual_policy], + "{{manual_policy}}": [client.manual_policy], + "{{ direct_policy }}": [client.direct_policy], + "{{direct_policy}}": [client.direct_policy], + "{{ source_auto_groups }}": source_auto_names, + "{{source_auto_groups}}": source_auto_names, + "{{ selector_groups }}": selector_names, + "{{selector_groups}}": selector_names, + } + for item in proxies: + expanded.extend(tokens.get(item, [item])) + return expanded + + +def _build_thin_filter_group( + *, + client_type: str, + client: ClientConfig, + group: ProxyGroupConfig, + selected_source_names: list[str], +) -> dict[str, Any]: + built: dict[str, Any] = { + "name": group.name, + "type": group.type, + "filter": group.filter, + } + if group.type == "url-test": + built["url"] = str(group.url or client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + if client_type == "mihomo": + built["use"] = selected_source_names + else: + built["include-all"] = True + return built + + +def _build_bundle_filter_group( + *, + client: ClientConfig, + group: ProxyGroupConfig, + all_proxy_names: list[str], +) -> dict[str, Any]: + matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)] + built: dict[str, Any] = { + "name": group.name, + "type": group.type, + "proxies": matched or [client.direct_policy], + } + if group.type == "url-test": + built["url"] = str(group.url or 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_custom_policy_groups( + *, + app_config: AppConfig, + client: ClientConfig, + source_auto_names: list[str], + selector_names: list[str], +) -> list[dict[str, Any]]: + groups: list[dict[str, Any]] = [] + for group in app_config.policy_groups: + built: dict[str, Any] = { + "name": group.name, + "type": group.type, + "proxies": _expand_proxy_tokens( + group.proxies, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ), + } + if group.type == "url-test": + built["url"] = str(group.url or client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + groups.append(built) + return groups + + +def build_thin_profile( + *, + client_type: str, + app_config: AppConfig, + client: ClientConfig, + selected_source_names: list[str], + base_url: str, + public_path: str, +) -> dict[str, Any]: + profile: dict[str, Any] = { + "mode": client.mode, + "ipv6": client.ipv6, + } if client.log_level: profile["log-level"] = client.log_level - if resolved_profile.client_type == "mihomo": + 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 - return profile - - -def _build_proxy_providers( - *, - resolved_profile, - base_url: str, - public_path: str, -) -> dict[str, dict[str, Any]]: proxy_providers: dict[str, dict[str, Any]] = {} - client = resolved_profile.client - for name in resolved_profile.selected_sources: - if resolved_profile.client_type == "mihomo": + for name in selected_source_names: + if client_type == "mihomo": proxy_providers[name] = { "type": "http", "url": f"{base_url}/{public_path}/providers/{name}.yaml", @@ -55,42 +154,10 @@ def _build_proxy_providers( "url": f"{base_url}/{public_path}/providers/{name}.yaml", "interval": client.provider_interval, } - return proxy_providers - - -def build_thin_profile( - *, - client_type: str, - app_config: AppConfig, - selected_source_names: list[str], - base_url: str, - public_path: str, -) -> dict[str, Any]: - resolved_profile = resolve_profile( - app_config=app_config, - client_type=client_type, - selected_source_names=selected_source_names, - ) - profile = _build_profile_header(resolved_profile=resolved_profile) - profile["proxy-providers"] = _build_proxy_providers( - resolved_profile=resolved_profile, - base_url=base_url, - public_path=public_path, - ) - profile["proxy-groups"] = build_thin_groups( - resolved_profile=resolved_profile, - selected_source_names=selected_source_names, - ) - profile["rule-providers"] = build_rule_provider_entries( - resolved_profile=resolved_profile, - base_url=base_url, - public_path=public_path, - ) - profile["rules"] = iter_resolved_rule_lines( - resolved_profile=resolved_profile, - include_rule_set_references=True, - inline_file_payloads=False, - ) + profile["proxy-providers"] = proxy_providers + profile["proxy-groups"] = _build_thin_groups(client_type, app_config, client, selected_source_names) + profile["rule-providers"] = build_rule_provider_entries(app_config, client, base_url, public_path) + profile["rules"] = build_rule_set_references(app_config, client) return profile @@ -98,24 +165,275 @@ def build_bundle_profile( *, client_type: str, app_config: AppConfig, + client: ClientConfig, snapshots: list[SourceSnapshot], ) -> dict[str, Any]: - resolved_profile = resolve_profile( - app_config=app_config, - client_type=client_type, - selected_source_names=[snapshot.name for snapshot in snapshots], - ) - profile = _build_profile_header(resolved_profile=resolved_profile) - proxy_nodes, source_proxy_names = build_bundle_proxy_inventory(snapshots) - profile["proxies"] = [node.to_proxy_dict() for node in proxy_nodes] - profile["proxy-groups"] = build_bundle_groups( - resolved_profile=resolved_profile, - snapshots=snapshots, - source_proxy_names=source_proxy_names, - ) - profile["rules"] = iter_resolved_rule_lines( - resolved_profile=resolved_profile, - include_rule_set_references=False, - inline_file_payloads=True, - ) + profile: dict[str, Any] = { + "mode": client.mode, + "ipv6": client.ipv6, + } + if client.log_level: + profile["log-level"] = client.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 + + proxies: list[dict[str, Any]] = [] + source_proxy_names: dict[str, list[str]] = {} + seen: set[str] = set() + for snapshot in snapshots: + 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) + names.append(name) + source_proxy_names[snapshot.name] = names + + profile["proxies"] = proxies + profile["proxy-groups"] = _build_bundle_groups(app_config, client, snapshots, source_proxy_names) + profile["rules"] = build_inline_rules(app_config, client) return profile + + +def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientConfig, selected_source_names: list[str]) -> list[dict[str, Any]]: + groups: list[dict[str, Any]] = [] + source_auto_names: list[str] = [] + + for source_name in selected_source_names: + display_name = app_config.sources[source_name].display_name or source_name + group_name = f"{display_name} 自动" + source_auto_names.append(group_name) + groups.append( + { + "name": group_name, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "use": [source_name], + } + ) + + if client_type == "mihomo": + mixed_auto = { + "name": client.mixed_auto_policy, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "include-all-providers": True, + } + manual = { + "name": client.manual_policy, + "type": "select", + "proxies": [client.direct_policy], + "include-all-providers": True, + } + else: + mixed_auto = { + "name": client.mixed_auto_policy, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "include-all": True, + } + manual = { + "name": client.manual_policy, + "type": "select", + "proxies": [client.direct_policy], + "include-all": True, + } + + groups.append(mixed_auto) + + region_names = [region.name for region in app_config.regions.values()] + selector_names = [*region_names, *[selector.name for selector in app_config.selector_groups]] + + groups.append( + { + "name": client.source_policy, + "type": "select", + "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy], + } + ) + groups.append(manual) + groups.append( + { + "name": client.main_policy, + "type": "select", + "proxies": [ + client.source_policy, + client.mixed_auto_policy, + *selector_names, + client.manual_policy, + client.direct_policy, + ], + } + ) + groups.extend( + _build_custom_policy_groups( + app_config=app_config, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ) + ) + for region in app_config.regions.values(): + group = { + "name": region.name, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "filter": region.filter, + "tolerance": region.tolerance, + } + if client_type == "mihomo": + group["include-all-providers"] = True + else: + group["include-all"] = True + groups.append(group) + + for selector in app_config.selector_groups: + if selector.filter: + groups.append( + _build_thin_filter_group( + client_type=client_type, + client=client, + group=selector, + selected_source_names=selected_source_names, + ) + ) + else: + groups.append( + { + "name": selector.name, + "type": selector.type, + "proxies": _expand_proxy_tokens( + selector.proxies, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ), + } + ) + return groups + + +def _build_bundle_groups( + app_config: AppConfig, + client: ClientConfig, + snapshots: list[SourceSnapshot], + source_proxy_names: dict[str, list[str]], +) -> list[dict[str, Any]]: + groups: list[dict[str, Any]] = [] + source_auto_names: list[str] = [] + all_proxy_names = [name for names in source_proxy_names.values() for name in names] + + for snapshot in snapshots: + group_name = f"{snapshot.display_name} 自动" + source_auto_names.append(group_name) + groups.append( + { + "name": group_name, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "proxies": source_proxy_names.get(snapshot.name) or [client.direct_policy], + } + ) + + groups.append( + { + "name": client.mixed_auto_policy, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "proxies": all_proxy_names or [client.direct_policy], + } + ) + + region_names = [region.name for region in app_config.regions.values()] + selector_names = [*region_names, *[selector.name for selector in app_config.selector_groups]] + + groups.append( + { + "name": client.source_policy, + "type": "select", + "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy], + } + ) + groups.append( + { + "name": client.manual_policy, + "type": "select", + "proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy], + } + ) + groups.append( + { + "name": client.main_policy, + "type": "select", + "proxies": [ + client.source_policy, + client.mixed_auto_policy, + *selector_names, + client.manual_policy, + client.direct_policy, + ], + } + ) + groups.extend( + _build_custom_policy_groups( + app_config=app_config, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ) + ) + for region in app_config.regions.values(): + matched = [name for name in all_proxy_names if re.search(region.filter, name)] + groups.append( + { + "name": region.name, + "type": "url-test", + "url": str(client.test_url), + "interval": client.test_interval, + "tolerance": region.tolerance, + "proxies": matched or [client.direct_policy], + } + ) + + for selector in app_config.selector_groups: + if selector.filter: + groups.append( + _build_bundle_filter_group( + client=client, + group=selector, + all_proxy_names=all_proxy_names, + ) + ) + else: + groups.append( + { + "name": selector.name, + "type": selector.type, + "proxies": _expand_proxy_tokens( + selector.proxies, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ), + } + ) + return groups diff --git a/app/services/proxy_pipeline.py b/app/services/proxy_pipeline.py deleted file mode 100644 index 699c840..0000000 --- a/app/services/proxy_pipeline.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from app.models import ProxyNode, SourceSnapshot - - -def source_auto_group_name(display_name: str) -> str: - return f"{display_name} 自动" - - -def proxy_dict_to_node(proxy: dict[str, Any]) -> ProxyNode | None: - name = str(proxy.get("name", "")).strip() - proxy_type = str(proxy.get("type", "")).strip() - if not name or not proxy_type: - return None - - attrs = dict(proxy) - attrs.pop("name", None) - attrs.pop("type", None) - server = attrs.pop("server", None) - port = attrs.pop("port", None) - udp = bool(attrs.pop("udp", True)) - return ProxyNode( - name=name, - type=proxy_type, - server=server, - port=port, - udp=udp, - attrs=attrs, - ) - - -def dedupe_proxy_nodes(nodes: list[ProxyNode]) -> list[ProxyNode]: - seen: set[str] = set() - deduped: list[ProxyNode] = [] - for node in nodes: - original = node.name - name = original - index = 2 - while name in seen: - name = f"{original} #{index}" - index += 1 - seen.add(name) - deduped.append(node.model_copy(update={"name": name})) - return deduped - - -def build_bundle_proxy_inventory( - snapshots: list[SourceSnapshot], -) -> tuple[list[ProxyNode], dict[str, list[str]]]: - all_nodes: list[ProxyNode] = [] - source_nodes: dict[str, list[ProxyNode]] = {} - - for snapshot in snapshots: - nodes: list[ProxyNode] = [] - for proxy in snapshot.document.proxies: - node = proxy_dict_to_node(proxy) - if node is not None: - nodes.append(node) - source_nodes[snapshot.name] = nodes - all_nodes.extend(nodes) - - deduped_all = dedupe_proxy_nodes(all_nodes) - source_proxy_names: dict[str, list[str]] = {} - cursor = 0 - for snapshot in snapshots: - original_nodes = source_nodes[snapshot.name] - count = len(original_nodes) - source_proxy_names[snapshot.name] = [node.name for node in deduped_all[cursor : cursor + count]] - cursor += count - - return deduped_all, source_proxy_names diff --git a/app/services/rule_resolver.py b/app/services/rule_resolver.py deleted file mode 100644 index 5a7fd76..0000000 --- a/app/services/rule_resolver.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import re - -import yaml - -from app.config import get_settings -from app.models import ClientConfig, ResolvedProfile, RuleConfig - -_CIDR_PATTERN = re.compile(r"^[0-9a-fA-F:.]+/\d+$") - - -def resolve_policy(policy: str, client: ClientConfig) -> str: - return ( - policy.replace("{{ main_policy }}", client.main_policy) - .replace("{{main_policy}}", client.main_policy) - .replace("{{ direct_policy }}", client.direct_policy) - .replace("{{direct_policy}}", client.direct_policy) - ) - - -def load_rule_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def load_rule_payload(path: Path) -> list[str]: - if path.suffix.lower() in {".yaml", ".yml"}: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - payload = data.get("payload", []) - if not isinstance(payload, list): - raise ValueError(f"Rule file {path.name} must contain a list field named 'payload'") - return [str(item).strip() for item in payload if str(item).strip()] - - lines: list[str] = [] - for line in path.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - lines.append(stripped) - return lines - - -def _render_payload_line(payload_line: str, behavior: str) -> str: - if "," in payload_line: - return payload_line - if behavior == "classical": - if _CIDR_PATTERN.fullmatch(payload_line): - prefix = "IP-CIDR6" if ":" in payload_line else "IP-CIDR" - return f"{prefix},{payload_line}" - return f"DOMAIN-SUFFIX,{payload_line}" - if behavior == "ipcidr": - return f"IP-CIDR,{payload_line}" - if behavior == "domain": - return f"DOMAIN-SUFFIX,{payload_line}" - return payload_line - - -def _attach_policy(rendered_line: str, target: str, append_no_resolve: bool) -> str: - parts = [part.strip() for part in rendered_line.split(",")] - if parts and parts[-1] == "no-resolve": - parts.insert(len(parts) - 1, target) - return ",".join(parts) - line = f"{rendered_line},{target}" - if append_no_resolve: - line += ",no-resolve" - return line - - -def iter_resolved_rule_lines( - *, - resolved_profile: ResolvedProfile, - include_rule_set_references: bool, - inline_file_payloads: bool, -) -> list[str]: - settings = get_settings() - lines: list[str] = [] - - for rule_name, rule in resolved_profile.rules.items(): - target = resolve_policy(rule.policy, resolved_profile.client) - for payload_line in rule.payload: - lines.append(_attach_policy(_render_payload_line(payload_line, rule.behavior), target, rule.no_resolve)) - - if not rule.file: - continue - - if include_rule_set_references: - ref_line = f"RULE-SET,{rule_name},{target}" - if rule.no_resolve: - ref_line += ",no-resolve" - lines.append(ref_line) - - if not inline_file_payloads: - continue - - path = (settings.rules_dir / rule.file).resolve() - if not path.is_file() or settings.rules_dir.resolve() not in path.parents: - raise FileNotFoundError(f"Rule file missing: {rule.file}") - for payload_line in load_rule_payload(path): - lines.append(_attach_policy(_render_payload_line(payload_line, rule.behavior), target, rule.no_resolve)) - - lines.append(f"MATCH,{resolved_profile.client.main_policy}") - return lines - - -def build_rule_provider_entries( - *, - resolved_profile: ResolvedProfile, - base_url: str, - public_path: str, -) -> dict[str, dict]: - providers: dict[str, dict] = {} - for name, rule in resolved_profile.rules.items(): - if not rule.file: - continue - providers[name] = { - "behavior": rule.behavior, - "format": rule.format, - "url": f"{base_url}/{public_path}/rules/{name}.yaml", - "interval": rule.interval, - } - return providers diff --git a/app/services/rules.py b/app/services/rules.py index ee7e624..49ffe74 100644 --- a/app/services/rules.py +++ b/app/services/rules.py @@ -1,69 +1,130 @@ from __future__ import annotations from pathlib import Path +import re +import yaml + +from app.config import get_settings from app.models import AppConfig, ClientConfig -from app.services.profile_resolver import resolve_profile -from app.services.rule_resolver import ( - build_rule_provider_entries as build_rule_provider_entries_for_profile, - iter_resolved_rule_lines, - load_rule_payload, - load_rule_text, - resolve_policy, -) + + +def resolve_policy(policy: str, client: ClientConfig) -> str: + return ( + policy.replace("{{ main_policy }}", client.main_policy) + .replace("{{main_policy}}", client.main_policy) + .replace("{{ direct_policy }}", client.direct_policy) + .replace("{{direct_policy}}", client.direct_policy) + ) + + +_CIDR_PATTERN = re.compile(r"^[0-9a-fA-F:.]+/\d+$") + + +def load_rule_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def load_rule_payload(path: Path) -> list[str]: + if path.suffix.lower() in {".yaml", ".yml"}: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + payload = data.get("payload", []) + if not isinstance(payload, list): + raise ValueError(f"Rule file {path.name} must contain a list field named 'payload'") + return [str(item).strip() for item in payload if str(item).strip()] + + lines: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + lines.append(stripped) + return lines + + +def _render_payload_line(payload_line: str, behavior: str) -> str: + if "," in payload_line: + return payload_line + if behavior == "classical": + if _CIDR_PATTERN.fullmatch(payload_line): + prefix = "IP-CIDR6" if ":" in payload_line else "IP-CIDR" + return f"{prefix},{payload_line}" + return f"DOMAIN-SUFFIX,{payload_line}" + if behavior == "ipcidr": + return f"IP-CIDR,{payload_line}" + if behavior == "domain": + return f"DOMAIN-SUFFIX,{payload_line}" + return payload_line + + +def _attach_policy(rendered_line: str, target: str, append_no_resolve: bool) -> str: + parts = [part.strip() for part in rendered_line.split(",")] + if parts and parts[-1] == "no-resolve": + parts.insert(len(parts) - 1, target) + line = ",".join(parts) + else: + line = f"{rendered_line},{target}" + if append_no_resolve: + line += ",no-resolve" + return line + + +def _resolve_rule_lines(rule_name: str, app_config: AppConfig, client: ClientConfig) -> list[str]: + rule = app_config.rules[rule_name] + target = resolve_policy(rule.policy, client) + lines: list[str] = [] + + for payload_line in rule.payload: + rendered = _render_payload_line(payload_line, rule.behavior) + lines.append(_attach_policy(rendered, target, rule.no_resolve)) + + if rule.file: + line = f"RULE-SET,{rule_name},{target}" + if rule.no_resolve: + line += ",no-resolve" + lines.append(line) + + return lines def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str): - resolved_profile = resolve_profile( - app_config=app_config, - client_type=_find_client_type(app_config, client), - selected_source_names=list(app_config.sources.keys()), - ) - return build_rule_provider_entries_for_profile( - resolved_profile=resolved_profile, - base_url=base_url, - public_path=public_path, - ) + providers: dict[str, dict] = {} + for name, rule in app_config.rules.items(): + if not rule.file: + continue + entry = { + "behavior": rule.behavior, + "format": rule.format, + "url": f"{base_url}/{public_path}/rules/{name}.yaml", + "interval": rule.interval, + } + providers[name] = entry + return providers def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]: - resolved_profile = resolve_profile( - app_config=app_config, - client_type=_find_client_type(app_config, client), - selected_source_names=list(app_config.sources.keys()), - ) - return iter_resolved_rule_lines( - resolved_profile=resolved_profile, - include_rule_set_references=True, - inline_file_payloads=False, - ) + refs: list[str] = [] + for name in app_config.rules: + refs.extend(_resolve_rule_lines(name, app_config, client)) + refs.append(f"MATCH,{client.main_policy}") + return refs def build_inline_rules(app_config: AppConfig, client: ClientConfig) -> list[str]: - resolved_profile = resolve_profile( - app_config=app_config, - client_type=_find_client_type(app_config, client), - selected_source_names=list(app_config.sources.keys()), - ) - return iter_resolved_rule_lines( - resolved_profile=resolved_profile, - include_rule_set_references=False, - inline_file_payloads=True, - ) - - -def _find_client_type(app_config: AppConfig, client: ClientConfig) -> str: - for client_type, candidate in app_config.clients.items(): - if candidate == client: - return client_type - raise KeyError("client config not found in app config") - - -__all__ = [ - "build_inline_rules", - "build_rule_provider_entries", - "build_rule_set_references", - "load_rule_payload", - "load_rule_text", - "resolve_policy", -] + settings = get_settings() + lines: list[str] = [] + for name, rule in app_config.rules.items(): + target = resolve_policy(rule.policy, client) + for payload_line in rule.payload: + rendered = _render_payload_line(payload_line, rule.behavior) + lines.append(_attach_policy(rendered, target, rule.no_resolve)) + if not rule.file: + continue + path = (settings.rules_dir / rule.file).resolve() + if not path.is_file() or settings.rules_dir.resolve() not in path.parents: + raise FileNotFoundError(f"Rule file missing: {rule.file}") + for payload_line in load_rule_payload(path): + rendered = _render_payload_line(payload_line, rule.behavior) + lines.append(_attach_policy(rendered, target, rule.no_resolve)) + lines.append(f"MATCH,{client.main_policy}") + return lines