重构
This commit is contained in:
252
app/services/conf_runtime.py
Normal file
252
app/services/conf_runtime.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.conf_models import (
|
||||
ConfBaseConfig,
|
||||
ConfGroup,
|
||||
ConfProfileConfig,
|
||||
ConfResolvedBuiltin,
|
||||
ConfResolvedGroup,
|
||||
ConfResolvedModule,
|
||||
ConfResolvedSource,
|
||||
ConfRuntimePlan,
|
||||
)
|
||||
from app.services.conf_loader import ConfConfigError
|
||||
from app.services.rules import load_rule_payload
|
||||
|
||||
_GROUP_TYPES = {"select", "url-test", "fallback", "load-balance"}
|
||||
_CIDR_CHARS = set("0123456789abcdefABCDEF:./")
|
||||
_SELECT_SPECIAL_MEMBERS = {"@source_auto_groups", "@all_proxies"}
|
||||
|
||||
|
||||
def _parse_group_member(token: str) -> str:
|
||||
if token.startswith("[]"):
|
||||
return token[2:]
|
||||
return token
|
||||
|
||||
|
||||
def _parse_group(group: ConfGroup, *, selectors: dict[str, str], known_group_names: set[str], path: Path) -> ConfResolvedGroup:
|
||||
if group.group_type not in _GROUP_TYPES:
|
||||
raise ConfConfigError(path, group.line_no, f"unsupported group type: {group.group_type}")
|
||||
if group.group_type == "select":
|
||||
selector_key: str | None = None
|
||||
members = [_parse_group_member(token) for token in group.tokens if token.strip()]
|
||||
if not members:
|
||||
raise ConfConfigError(path, group.line_no, "select group must define at least one member")
|
||||
for token in group.tokens:
|
||||
stripped = token.strip()
|
||||
if stripped.startswith("@"):
|
||||
if stripped in _SELECT_SPECIAL_MEMBERS:
|
||||
continue
|
||||
selector_key = stripped[1:]
|
||||
if selector_key not in selectors:
|
||||
raise ConfConfigError(path, group.line_no, f"group references unknown selector: {selector_key}")
|
||||
continue
|
||||
if stripped.startswith("[]"):
|
||||
target = stripped[2:]
|
||||
if target not in {"DIRECT", "REJECT"} and target not in known_group_names:
|
||||
raise ConfConfigError(path, group.line_no, f"group references unknown group: {target}")
|
||||
return ConfResolvedGroup(
|
||||
name=group.name,
|
||||
group_type=group.group_type,
|
||||
members=[] if selector_key else members,
|
||||
selector_key=selector_key,
|
||||
raw=group.raw,
|
||||
)
|
||||
|
||||
if not group.tokens:
|
||||
raise ConfConfigError(path, group.line_no, f"{group.group_type} group requires selector/member arguments")
|
||||
|
||||
first = group.tokens[0].strip()
|
||||
selector_key: str | None = None
|
||||
members: list[str] = []
|
||||
if first.startswith("@"):
|
||||
selector_key = first[1:]
|
||||
if selector_key not in selectors:
|
||||
raise ConfConfigError(path, group.line_no, f"group references unknown selector: {selector_key}")
|
||||
else:
|
||||
members.append(_parse_group_member(first))
|
||||
if first.startswith("[]"):
|
||||
target = first[2:]
|
||||
if target not in {"DIRECT", "REJECT"} and target not in known_group_names:
|
||||
raise ConfConfigError(path, group.line_no, f"group references unknown group: {target}")
|
||||
|
||||
url = group.tokens[1].strip() if len(group.tokens) > 1 and group.tokens[1].strip() else None
|
||||
interval: int | None = None
|
||||
tolerance: int | None = None
|
||||
if len(group.tokens) > 2 and group.tokens[2].strip():
|
||||
try:
|
||||
interval = int(group.tokens[2].split(",", 1)[0].strip())
|
||||
except ValueError as exc:
|
||||
raise ConfConfigError(path, group.line_no, f"invalid group interval: {group.tokens[2]}") from exc
|
||||
if len(group.tokens) > 3 and group.tokens[3].strip():
|
||||
tolerance_token = group.tokens[3].split(",")[-1].strip()
|
||||
if tolerance_token:
|
||||
try:
|
||||
tolerance = int(tolerance_token)
|
||||
except ValueError as exc:
|
||||
raise ConfConfigError(path, group.line_no, f"invalid group tolerance: {group.tokens[3]}") from exc
|
||||
|
||||
return ConfResolvedGroup(
|
||||
name=group.name,
|
||||
group_type=group.group_type,
|
||||
members=members,
|
||||
selector_key=selector_key,
|
||||
url=url,
|
||||
interval=interval,
|
||||
tolerance=tolerance,
|
||||
raw=group.raw,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_builtin_rule_line(builtin_type: str, value: str, policy: str) -> str:
|
||||
if builtin_type == "GEOIP":
|
||||
return f"GEOIP,{value},{policy}"
|
||||
return f"MATCH,{policy}"
|
||||
|
||||
|
||||
def _render_rule_line(payload_line: str, policy: str) -> str:
|
||||
rendered = payload_line.strip()
|
||||
if not rendered:
|
||||
return ""
|
||||
if "," not in rendered:
|
||||
if "/" in rendered and set(rendered) <= _CIDR_CHARS:
|
||||
prefix = "IP-CIDR6" if ":" in rendered else "IP-CIDR"
|
||||
rendered = f"{prefix},{rendered}"
|
||||
else:
|
||||
rendered = f"DOMAIN-SUFFIX,{rendered}"
|
||||
parts = [part.strip() for part in rendered.split(",")]
|
||||
if parts and parts[-1] == "no-resolve":
|
||||
parts.insert(len(parts) - 1, policy)
|
||||
return ",".join(parts)
|
||||
return f"{rendered},{policy}"
|
||||
|
||||
|
||||
def build_conf_rule_lines(plan: ConfRuntimePlan, *, base_path: Path) -> list[str]:
|
||||
config_dir = base_path.resolve().parent
|
||||
lines: list[str] = []
|
||||
final_lines: list[str] = []
|
||||
|
||||
for rule in plan.prepend_rules:
|
||||
if rule.startswith("MATCH,"):
|
||||
final_lines.append(rule)
|
||||
else:
|
||||
lines.append(rule)
|
||||
|
||||
for module in plan.selected_modules:
|
||||
module_path = (config_dir / module.path).resolve()
|
||||
if not module_path.is_file():
|
||||
raise FileNotFoundError(f"Rule file missing: {module.path}")
|
||||
for payload_line in load_rule_payload(module_path):
|
||||
rendered = _render_rule_line(payload_line, module.policy)
|
||||
if rendered:
|
||||
if rendered.startswith("MATCH,"):
|
||||
final_lines.append(rendered)
|
||||
else:
|
||||
lines.append(rendered)
|
||||
|
||||
for builtin in plan.selected_builtins:
|
||||
if builtin.rule_line.startswith("MATCH,"):
|
||||
final_lines.append(builtin.rule_line)
|
||||
else:
|
||||
lines.append(builtin.rule_line)
|
||||
|
||||
for rule in plan.append_rules:
|
||||
if rule.startswith("MATCH,"):
|
||||
final_lines.append(rule)
|
||||
else:
|
||||
lines.append(rule)
|
||||
|
||||
return [*lines, *final_lines]
|
||||
|
||||
|
||||
def resolve_conf_runtime_plan(
|
||||
*,
|
||||
profile_key: str,
|
||||
base_config: ConfBaseConfig,
|
||||
profile_config: ConfProfileConfig,
|
||||
base_path: Path,
|
||||
) -> ConfRuntimePlan:
|
||||
if not profile_config.enabled:
|
||||
raise ConfConfigError(base_path, 1, f"profile is disabled: {profile_key}")
|
||||
|
||||
selectors = {item.key: item.regex for item in base_config.selectors}
|
||||
source_map = {item.key: item for item in base_config.sources}
|
||||
module_map = {item.key: item for item in base_config.modules}
|
||||
builtin_map = {item.key: item for item in base_config.builtins}
|
||||
override_map = {item.module_key: item.policy for item in profile_config.override_policies}
|
||||
|
||||
selected_source_keys = profile_config.sources or [item.key for item in base_config.sources if item.enabled]
|
||||
selected_sources = [
|
||||
ConfResolvedSource(
|
||||
key=source_map[key].key,
|
||||
display_name=str(source_map[key].options.get("display_name", source_map[key].key)).strip() or source_map[key].key,
|
||||
source_type=source_map[key].source_type,
|
||||
value=source_map[key].value,
|
||||
enabled=source_map[key].enabled,
|
||||
cache_ttl=source_map[key].cache_ttl,
|
||||
options=dict(source_map[key].options),
|
||||
)
|
||||
for key in selected_source_keys
|
||||
if source_map[key].enabled
|
||||
]
|
||||
|
||||
excluded_modules = set(profile_config.exclude_modules)
|
||||
include_keys = profile_config.include_modules or [item.key for item in base_config.modules if item.enabled]
|
||||
selected_modules: list[ConfResolvedModule] = []
|
||||
for key in include_keys:
|
||||
module = module_map[key]
|
||||
if not module.enabled or key in excluded_modules:
|
||||
continue
|
||||
selected_modules.append(
|
||||
ConfResolvedModule(
|
||||
key=module.key,
|
||||
path=module.path,
|
||||
policy=override_map.get(module.key, module.policy),
|
||||
order=module.order,
|
||||
enabled=module.enabled,
|
||||
)
|
||||
)
|
||||
selected_modules.sort(key=lambda item: (item.order, item.key))
|
||||
|
||||
builtin_keys = profile_config.include_builtins or [item.key for item in base_config.builtins if item.enabled]
|
||||
selected_builtins: list[ConfResolvedBuiltin] = []
|
||||
for key in builtin_keys:
|
||||
builtin = builtin_map[key]
|
||||
if not builtin.enabled:
|
||||
continue
|
||||
selected_builtins.append(
|
||||
ConfResolvedBuiltin(
|
||||
key=builtin.key,
|
||||
rule_line=_resolve_builtin_rule_line(builtin.builtin_type, builtin.value, builtin.policy),
|
||||
policy=builtin.policy,
|
||||
order=builtin.order,
|
||||
enabled=builtin.enabled,
|
||||
)
|
||||
)
|
||||
selected_builtins.sort(key=lambda item: (item.order, item.key))
|
||||
|
||||
known_group_names = {group.name for group in base_config.groups}
|
||||
resolved_groups = [
|
||||
_parse_group(group, selectors=selectors, known_group_names=known_group_names, path=base_path)
|
||||
for group in base_config.groups
|
||||
]
|
||||
|
||||
return ConfRuntimePlan(
|
||||
profile_key=profile_key,
|
||||
profile_name=profile_config.name,
|
||||
selected_sources=selected_sources,
|
||||
selected_modules=selected_modules,
|
||||
selected_builtins=selected_builtins,
|
||||
selectors=selectors,
|
||||
groups=resolved_groups,
|
||||
prepend_rules=[item.value for item in profile_config.prepend_rules],
|
||||
append_rules=[item.value for item in profile_config.append_rules],
|
||||
mode=base_config.mode,
|
||||
allow_lan=base_config.allow_lan,
|
||||
log_level=base_config.log_level,
|
||||
ipv6=base_config.ipv6,
|
||||
append_userinfo_header=base_config.append_userinfo_header,
|
||||
userinfo_source_policy=base_config.userinfo_source_policy,
|
||||
)
|
||||
Reference in New Issue
Block a user