diff --git a/app/main.py b/app/main.py index eea1bf0..14b177f 100644 --- a/app/main.py +++ b/app/main.py @@ -150,7 +150,6 @@ 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("/"), @@ -199,7 +198,6 @@ 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 867bb7d..aabce49 100644 --- a/app/models.py +++ b/app/models.py @@ -82,6 +82,26 @@ 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 @@ -110,3 +130,13 @@ 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 new file mode 100644 index 0000000..2351111 --- /dev/null +++ b/app/services/policy_group_builder.py @@ -0,0 +1,309 @@ +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 new file mode 100644 index 0000000..9276adf --- /dev/null +++ b/app/services/profile_resolver.py @@ -0,0 +1,21 @@ +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 67acc3b..e17f8e9 100644 --- a/app/services/profiles.py +++ b/app/services/profiles.py @@ -1,143 +1,44 @@ from __future__ import annotations -import re from typing import Any import yaml -from app.models import AppConfig, ClientConfig, ProxyGroupConfig, SourceSnapshot -from app.services.rules import build_inline_rules, build_rule_provider_entries, build_rule_set_references +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 def dump_yaml(data: dict[str, Any]) -> str: return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False) -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, - } +def _build_profile_header(*, resolved_profile) -> dict[str, Any]: + client = resolved_profile.client + 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 resolved_profile.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]] = {} - for name in selected_source_names: - if client_type == "mihomo": + client = resolved_profile.client + for name in resolved_profile.selected_sources: + if resolved_profile.client_type == "mihomo": proxy_providers[name] = { "type": "http", "url": f"{base_url}/{public_path}/providers/{name}.yaml", @@ -154,10 +55,42 @@ def build_thin_profile( "url": f"{base_url}/{public_path}/providers/{name}.yaml", "interval": client.provider_interval, } - 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 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, + ) return profile @@ -165,275 +98,24 @@ def build_bundle_profile( *, client_type: str, app_config: AppConfig, - client: ClientConfig, snapshots: list[SourceSnapshot], ) -> dict[str, Any]: - 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) + 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, + ) 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 new file mode 100644 index 0000000..699c840 --- /dev/null +++ b/app/services/proxy_pipeline.py @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..5a7fd76 --- /dev/null +++ b/app/services/rule_resolver.py @@ -0,0 +1,122 @@ +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 49ffe74..ee7e624 100644 --- a/app/services/rules.py +++ b/app/services/rules.py @@ -1,130 +1,69 @@ from __future__ import annotations from pathlib import Path -import re -import yaml - -from app.config import get_settings from app.models import AppConfig, ClientConfig - - -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 +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 build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str): - 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 + 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, + ) def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]: - 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 + 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, + ) def build_inline_rules(app_config: AppConfig, client: ClientConfig) -> list[str]: - 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 + 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", +]