重构
This commit is contained in:
12
app/services/conf_config_store.py
Normal file
12
app/services/conf_config_store.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import get_settings
|
||||
from app.conf_models import ConfBaseConfig, ConfProfileConfig
|
||||
from app.services.conf_loader import load_conf_base, load_conf_profiles
|
||||
|
||||
|
||||
def load_conf_store() -> tuple[ConfBaseConfig, dict[str, ConfProfileConfig]]:
|
||||
settings = get_settings()
|
||||
base = load_conf_base(settings.conf_base_file)
|
||||
profiles = load_conf_profiles(settings.conf_profiles_dir, base_config=base)
|
||||
return base, profiles
|
||||
@@ -6,7 +6,17 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.conf_models import ConfBaseConfig, ConfBuiltin, ConfGroup, ConfModule, ConfSelector, ConfSource
|
||||
from app.conf_models import (
|
||||
ConfBaseConfig,
|
||||
ConfBuiltin,
|
||||
ConfGroup,
|
||||
ConfInlineRule,
|
||||
ConfModule,
|
||||
ConfPolicyOverride,
|
||||
ConfProfileConfig,
|
||||
ConfSelector,
|
||||
ConfSource,
|
||||
)
|
||||
|
||||
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
||||
_SCALAR_KEYS = {
|
||||
@@ -23,6 +33,15 @@ _SCALAR_KEYS = {
|
||||
_BOOL_KEYS = {"allow_lan", "ipv6", "append_userinfo_header"}
|
||||
_MULTI_KEYS = {"source", "selector", "group", "module", "builtin"}
|
||||
_SOURCE_TYPES = {"url", "file", "inline", "base64"}
|
||||
_PROFILE_SCALAR_KEYS = {
|
||||
"name",
|
||||
"enabled",
|
||||
"sources",
|
||||
"include_modules",
|
||||
"exclude_modules",
|
||||
"include_builtins",
|
||||
}
|
||||
_PROFILE_MULTI_KEYS = {"override_policy", "prepend_rule", "append_rule"}
|
||||
|
||||
|
||||
class ConfConfigError(ValueError):
|
||||
@@ -60,6 +79,25 @@ def _parse_csv_fields(value: str, *, path: Path, line_no: int) -> list[str]:
|
||||
return [field.strip() for field in row]
|
||||
|
||||
|
||||
def _split_key_value(raw_line: str, *, path: Path, line_no: int) -> tuple[str, str]:
|
||||
if "=" not in raw_line:
|
||||
raise ConfConfigError(path, line_no, "expected key = value")
|
||||
key, value = [part.strip() for part in raw_line.split("=", 1)]
|
||||
if not key:
|
||||
raise ConfConfigError(path, line_no, "key cannot be empty")
|
||||
return key, value
|
||||
|
||||
|
||||
def _iter_conf_lines(path: Path) -> list[tuple[int, str]]:
|
||||
records: list[tuple[int, str]] = []
|
||||
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
records.append((line_no, raw_line))
|
||||
return records
|
||||
|
||||
|
||||
def _ensure_named_records(records: list[Any], *, key: str, path: Path) -> None:
|
||||
seen: dict[str, int] = {}
|
||||
for record in records:
|
||||
@@ -175,20 +213,75 @@ def _parse_builtin(value: str, *, path: Path, line_no: int) -> ConfBuiltin:
|
||||
)
|
||||
|
||||
|
||||
def _parse_string_list(value: str, *, path: Path, line_no: int) -> list[str]:
|
||||
fields = _parse_csv_fields(_expand_env(value), path=path, line_no=line_no)
|
||||
return [field for field in fields if field]
|
||||
|
||||
|
||||
def _parse_override_policy(value: str, *, path: Path, line_no: int) -> ConfPolicyOverride:
|
||||
fields = _parse_csv_fields(value, path=path, line_no=line_no)
|
||||
if len(fields) != 2:
|
||||
raise ConfConfigError(path, line_no, "override_policy requires exactly 2 fields: module_key,new_policy")
|
||||
module_key, policy = fields
|
||||
if not module_key or not policy:
|
||||
raise ConfConfigError(path, line_no, "override_policy module key and policy cannot be empty")
|
||||
return ConfPolicyOverride(module_key=module_key, policy=policy, line_no=line_no)
|
||||
|
||||
|
||||
def _parse_inline_rule(value: str, *, path: Path, line_no: int, key: str) -> ConfInlineRule:
|
||||
expanded = _expand_env(value).strip()
|
||||
if not expanded:
|
||||
raise ConfConfigError(path, line_no, f"{key} cannot be empty")
|
||||
return ConfInlineRule(value=expanded, line_no=line_no)
|
||||
|
||||
|
||||
def _validate_profile_references(
|
||||
profile: ConfProfileConfig,
|
||||
base: ConfBaseConfig,
|
||||
*,
|
||||
path: Path,
|
||||
field_lines: dict[str, int],
|
||||
) -> None:
|
||||
source_keys = {item.key for item in base.sources}
|
||||
module_keys = {item.key for item in base.modules}
|
||||
builtin_keys = {item.key for item in base.builtins}
|
||||
|
||||
for source in profile.sources:
|
||||
if source not in source_keys:
|
||||
raise ConfConfigError(path, field_lines.get("sources", 1), f"profile references unknown source: {source}")
|
||||
for module in profile.include_modules:
|
||||
if module not in module_keys:
|
||||
raise ConfConfigError(
|
||||
path,
|
||||
field_lines.get("include_modules", 1),
|
||||
f"profile references unknown module in include_modules: {module}",
|
||||
)
|
||||
for module in profile.exclude_modules:
|
||||
if module not in module_keys:
|
||||
raise ConfConfigError(
|
||||
path,
|
||||
field_lines.get("exclude_modules", 1),
|
||||
f"profile references unknown module in exclude_modules: {module}",
|
||||
)
|
||||
for builtin in profile.include_builtins:
|
||||
if builtin not in builtin_keys:
|
||||
raise ConfConfigError(
|
||||
path,
|
||||
field_lines.get("include_builtins", 1),
|
||||
f"profile references unknown builtin in include_builtins: {builtin}",
|
||||
)
|
||||
for override in profile.override_policies:
|
||||
if override.module_key not in module_keys:
|
||||
raise ConfConfigError(path, override.line_no, f"override_policy references unknown module: {override.module_key}")
|
||||
|
||||
|
||||
def load_conf_base(path: Path) -> ConfBaseConfig:
|
||||
config = ConfBaseConfig()
|
||||
scalar_values: dict[str, Any] = {}
|
||||
config_dir = path.resolve().parent
|
||||
|
||||
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
if "=" not in raw_line:
|
||||
raise ConfConfigError(path, line_no, "expected key = value")
|
||||
key, value = [part.strip() for part in raw_line.split("=", 1)]
|
||||
if not key:
|
||||
raise ConfConfigError(path, line_no, "key cannot be empty")
|
||||
for line_no, raw_line in _iter_conf_lines(path):
|
||||
key, value = _split_key_value(raw_line, path=path, line_no=line_no)
|
||||
if key in _SCALAR_KEYS:
|
||||
if key in scalar_values:
|
||||
raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}")
|
||||
@@ -220,3 +313,48 @@ def load_conf_base(path: Path) -> ConfBaseConfig:
|
||||
_ensure_named_records(config.modules, key="key", path=path)
|
||||
_ensure_named_records(config.builtins, key="key", path=path)
|
||||
return config
|
||||
|
||||
|
||||
def load_conf_profile(path: Path, *, base_config: ConfBaseConfig) -> ConfProfileConfig:
|
||||
profile = ConfProfileConfig()
|
||||
scalar_values: dict[str, Any] = {}
|
||||
field_lines: dict[str, int] = {}
|
||||
|
||||
for line_no, raw_line in _iter_conf_lines(path):
|
||||
key, value = _split_key_value(raw_line, path=path, line_no=line_no)
|
||||
if key in _PROFILE_SCALAR_KEYS:
|
||||
if key in scalar_values:
|
||||
raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}")
|
||||
field_lines[key] = line_no
|
||||
if key == "enabled":
|
||||
scalar_values[key] = _parse_bool(_expand_env(value), path=path, line_no=line_no, key=key)
|
||||
elif key in {"sources", "include_modules", "exclude_modules", "include_builtins"}:
|
||||
scalar_values[key] = _parse_string_list(value, path=path, line_no=line_no)
|
||||
else:
|
||||
scalar_values[key] = _expand_env(value)
|
||||
continue
|
||||
if key not in _PROFILE_MULTI_KEYS:
|
||||
raise ConfConfigError(path, line_no, f"unknown directive: {key}")
|
||||
if key == "override_policy":
|
||||
profile.override_policies.append(_parse_override_policy(value, path=path, line_no=line_no))
|
||||
elif key == "prepend_rule":
|
||||
profile.prepend_rules.append(_parse_inline_rule(value, path=path, line_no=line_no, key=key))
|
||||
elif key == "append_rule":
|
||||
profile.append_rules.append(_parse_inline_rule(value, path=path, line_no=line_no, key=key))
|
||||
|
||||
for key, parsed_value in scalar_values.items():
|
||||
setattr(profile, key, parsed_value)
|
||||
|
||||
_validate_profile_references(profile, base_config, path=path, field_lines=field_lines)
|
||||
return profile
|
||||
|
||||
|
||||
def load_conf_profiles(directory: Path, *, base_config: ConfBaseConfig) -> dict[str, ConfProfileConfig]:
|
||||
profiles: dict[str, ConfProfileConfig] = {}
|
||||
for path in sorted(directory.glob("*.conf")):
|
||||
profile = load_conf_profile(path, base_config=base_config)
|
||||
profile_key = path.stem
|
||||
if profile_key in profiles:
|
||||
raise ConfConfigError(path, 1, f"duplicate profile file key: {profile_key}")
|
||||
profiles[profile_key] = profile
|
||||
return profiles
|
||||
|
||||
353
app/services/conf_profiles.py
Normal file
353
app/services/conf_profiles.py
Normal file
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.conf_models import ConfResolvedModule, ConfRuntimePlan
|
||||
from app.models import ClientConfig, SourceConfig, SourceSnapshot
|
||||
from app.services.rules import load_rule_payload
|
||||
|
||||
|
||||
def _source_auto_group_name(source: Any) -> str:
|
||||
display_name = source["display_name"] if isinstance(source, dict) else source.display_name
|
||||
return f"{display_name} 自动"
|
||||
|
||||
|
||||
def conf_source_to_source_config(source: dict[str, Any] | Any, *, base_path: Path) -> SourceConfig:
|
||||
source_type = source["source_type"] if isinstance(source, dict) else source.source_type
|
||||
value = source["value"] if isinstance(source, dict) else source.value
|
||||
options = source["options"] if isinstance(source, dict) else source.options
|
||||
cache_ttl = source["cache_ttl"] if isinstance(source, dict) else source.cache_ttl
|
||||
key = source["key"] if isinstance(source, dict) else source.key
|
||||
|
||||
if source_type not in {"url", "file"}:
|
||||
raise ValueError(f"Unsupported conf source type for runtime route: {source_type}")
|
||||
|
||||
resolved_value = value
|
||||
if source_type == "file":
|
||||
candidate = Path(value)
|
||||
if not candidate.is_absolute():
|
||||
candidate = (base_path.resolve().parent / candidate).resolve()
|
||||
resolved_value = str(candidate)
|
||||
|
||||
kind = str(options.get("kind", "auto")).strip() or "auto"
|
||||
display_name = str(options.get("display_name", key)).strip() or key
|
||||
prefix = str(options.get("prefix", "")).strip()
|
||||
suffix = str(options.get("suffix", "")).strip()
|
||||
include_regex = str(options.get("include_regex", "")).strip() or None
|
||||
exclude_regex = str(options.get("exclude_regex", "")).strip() or None
|
||||
headers: dict[str, str] = {}
|
||||
for option_key, option_value in options.items():
|
||||
if option_key.startswith("header."):
|
||||
header_name = option_key.split(".", 1)[1].strip()
|
||||
if header_name:
|
||||
headers[header_name] = option_value
|
||||
|
||||
return SourceConfig(
|
||||
enabled=True,
|
||||
kind=kind,
|
||||
url=resolved_value,
|
||||
display_name=display_name,
|
||||
headers=headers,
|
||||
include_regex=include_regex,
|
||||
exclude_regex=exclude_regex,
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
cache_ttl_seconds=cache_ttl,
|
||||
)
|
||||
|
||||
|
||||
def _module_rule_provider_entry(
|
||||
module: ConfResolvedModule,
|
||||
*,
|
||||
client: ClientConfig,
|
||||
base_url: str,
|
||||
public_path: str,
|
||||
profile_key: str,
|
||||
) -> dict[str, Any]:
|
||||
suffix = Path(module.path).suffix.lower()
|
||||
return {
|
||||
"behavior": "classical",
|
||||
"format": "yaml" if suffix in {".yaml", ".yml"} else "text",
|
||||
"url": f"{base_url}/{public_path}/conf/rules/{profile_key}/{module.key}.yaml",
|
||||
"interval": client.rule_interval,
|
||||
}
|
||||
|
||||
|
||||
def _is_inline_module(module: ConfResolvedModule) -> bool:
|
||||
return Path(module.path).suffix.lower() not in {".yaml", ".yml"}
|
||||
|
||||
|
||||
def build_conf_rule_provider_entries(
|
||||
plan: ConfRuntimePlan,
|
||||
*,
|
||||
client: ClientConfig,
|
||||
base_url: str,
|
||||
public_path: str,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
module.key: _module_rule_provider_entry(module, client=client, base_url=base_url, public_path=public_path, profile_key=plan.profile_key)
|
||||
for module in plan.selected_modules
|
||||
if not _is_inline_module(module)
|
||||
}
|
||||
|
||||
|
||||
def _render_inline_rule(payload_line: str, policy: str) -> str:
|
||||
rendered = payload_line.strip()
|
||||
if not rendered:
|
||||
return ""
|
||||
if rendered.endswith(",no-resolve"):
|
||||
parts = [part.strip() for part in rendered.split(",")]
|
||||
parts.insert(len(parts) - 1, policy)
|
||||
return ",".join(parts)
|
||||
return f"{rendered},{policy}"
|
||||
|
||||
|
||||
def build_conf_rule_set_references(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:
|
||||
if _is_inline_module(module):
|
||||
module_path = (config_dir / module.path).resolve()
|
||||
for payload_line in load_rule_payload(module_path):
|
||||
rendered = _render_inline_rule(payload_line, module.policy)
|
||||
if rendered:
|
||||
lines.append(rendered)
|
||||
else:
|
||||
lines.append(f"RULE-SET,{module.key},{module.policy}")
|
||||
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 _build_group_for_thin(
|
||||
group: Any,
|
||||
*,
|
||||
client_type: str,
|
||||
client: ClientConfig,
|
||||
selected_source_names: list[str],
|
||||
source_auto_names: list[str],
|
||||
selectors: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
built: dict[str, Any] = {
|
||||
"name": group.name,
|
||||
"type": group.group_type,
|
||||
}
|
||||
if group.selector_key:
|
||||
built["filter"] = selectors[group.selector_key]
|
||||
if client_type == "mihomo":
|
||||
built["use"] = selected_source_names
|
||||
else:
|
||||
built["include-all"] = True
|
||||
else:
|
||||
proxies: list[str] = []
|
||||
include_all = False
|
||||
for member in group.members:
|
||||
if member == "@source_auto_groups":
|
||||
proxies.extend(source_auto_names)
|
||||
elif member == "@all_proxies":
|
||||
include_all = True
|
||||
else:
|
||||
proxies.append(member)
|
||||
built["proxies"] = proxies
|
||||
if include_all:
|
||||
if client_type == "mihomo":
|
||||
built["include-all-providers"] = True
|
||||
else:
|
||||
built["include-all"] = True
|
||||
if group.group_type != "select":
|
||||
built["url"] = group.url or str(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_group_for_bundle(
|
||||
group: Any,
|
||||
*,
|
||||
client: ClientConfig,
|
||||
selectors: dict[str, str],
|
||||
all_proxy_names: list[str],
|
||||
source_auto_names: list[str],
|
||||
) -> dict[str, Any]:
|
||||
built: dict[str, Any] = {
|
||||
"name": group.name,
|
||||
"type": group.group_type,
|
||||
}
|
||||
if group.selector_key:
|
||||
pattern = selectors[group.selector_key]
|
||||
built["proxies"] = [name for name in all_proxy_names if re.search(pattern, name)] or [client.direct_policy]
|
||||
else:
|
||||
proxies: list[str] = []
|
||||
for member in group.members:
|
||||
if member == "@source_auto_groups":
|
||||
proxies.extend(source_auto_names)
|
||||
continue
|
||||
if member == "@all_proxies":
|
||||
proxies.extend(all_proxy_names)
|
||||
continue
|
||||
else:
|
||||
proxies.append(member)
|
||||
built["proxies"] = proxies or [client.direct_policy]
|
||||
if group.group_type != "select":
|
||||
built["url"] = group.url or str(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_conf_thin_profile(
|
||||
*,
|
||||
client_type: str,
|
||||
client: ClientConfig,
|
||||
plan: ConfRuntimePlan,
|
||||
base_path: Path,
|
||||
base_url: str,
|
||||
public_path: str,
|
||||
) -> dict[str, Any]:
|
||||
profile: dict[str, Any] = {
|
||||
"mode": plan.mode or client.mode,
|
||||
"ipv6": client.ipv6 if plan.ipv6 is None else plan.ipv6,
|
||||
"proxy-providers": {},
|
||||
}
|
||||
log_level = plan.log_level or client.log_level
|
||||
if log_level:
|
||||
profile["log-level"] = 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 if plan.allow_lan is None else plan.allow_lan
|
||||
|
||||
selected_source_names = [item.key for item in plan.selected_sources]
|
||||
source_auto_names = [_source_auto_group_name(item) for item in plan.selected_sources]
|
||||
for name in selected_source_names:
|
||||
source = next(item for item in plan.selected_sources if item.key == name)
|
||||
group_name = _source_auto_group_name(source)
|
||||
if client_type == "mihomo":
|
||||
profile["proxy-providers"][name] = {
|
||||
"type": "http",
|
||||
"url": f"{base_url}/{public_path}/conf/providers/{plan.profile_key}/{name}.yaml",
|
||||
"path": f"./providers/{plan.profile_key}-{name}.yaml",
|
||||
"interval": client.provider_interval,
|
||||
"health-check": {
|
||||
"enable": True,
|
||||
"url": str(client.test_url),
|
||||
"interval": client.test_interval,
|
||||
},
|
||||
}
|
||||
else:
|
||||
profile["proxy-providers"][name] = {
|
||||
"url": f"{base_url}/{public_path}/conf/providers/{plan.profile_key}/{name}.yaml",
|
||||
"interval": client.provider_interval,
|
||||
}
|
||||
profile.setdefault("proxy-groups", []).append(
|
||||
{
|
||||
"name": group_name,
|
||||
"type": "url-test",
|
||||
"url": str(client.test_url),
|
||||
"interval": client.test_interval,
|
||||
"use": [name],
|
||||
}
|
||||
)
|
||||
|
||||
profile["proxy-groups"].extend(
|
||||
[
|
||||
_build_group_for_thin(
|
||||
group,
|
||||
client_type=client_type,
|
||||
client=client,
|
||||
selected_source_names=selected_source_names,
|
||||
source_auto_names=source_auto_names,
|
||||
selectors=plan.selectors,
|
||||
)
|
||||
for group in plan.groups
|
||||
]
|
||||
)
|
||||
profile["rule-providers"] = build_conf_rule_provider_entries(plan, client=client, base_url=base_url, public_path=public_path)
|
||||
profile["rules"] = build_conf_rule_set_references(plan, base_path=base_path)
|
||||
return profile
|
||||
|
||||
|
||||
def build_conf_bundle_profile(*, client_type: str, client: ClientConfig, plan: ConfRuntimePlan, snapshots: list[SourceSnapshot], rules: list[str]) -> dict[str, Any]:
|
||||
profile: dict[str, Any] = {
|
||||
"mode": plan.mode or client.mode,
|
||||
"ipv6": client.ipv6 if plan.ipv6 is None else plan.ipv6,
|
||||
}
|
||||
log_level = plan.log_level or client.log_level
|
||||
if log_level:
|
||||
profile["log-level"] = 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 if plan.allow_lan is None else plan.allow_lan
|
||||
|
||||
proxies: list[dict[str, Any]] = []
|
||||
all_proxy_names: list[str] = []
|
||||
source_proxy_names: dict[str, list[str]] = {}
|
||||
seen: set[str] = set()
|
||||
for snapshot in snapshots:
|
||||
current_source_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)
|
||||
all_proxy_names.append(name)
|
||||
current_source_names.append(name)
|
||||
source_proxy_names[snapshot.name] = current_source_names
|
||||
|
||||
profile["proxies"] = proxies
|
||||
profile["proxy-groups"] = []
|
||||
for source in plan.selected_sources:
|
||||
profile["proxy-groups"].append(
|
||||
{
|
||||
"name": _source_auto_group_name(source),
|
||||
"type": "url-test",
|
||||
"url": str(client.test_url),
|
||||
"interval": client.test_interval,
|
||||
"proxies": source_proxy_names.get(source.key) or [client.direct_policy],
|
||||
}
|
||||
)
|
||||
profile["proxy-groups"].extend(
|
||||
[
|
||||
_build_group_for_bundle(
|
||||
group,
|
||||
client=client,
|
||||
selectors=plan.selectors,
|
||||
all_proxy_names=all_proxy_names,
|
||||
source_auto_names=[_source_auto_group_name(item) for item in plan.selected_sources],
|
||||
)
|
||||
for group in plan.groups
|
||||
]
|
||||
)
|
||||
profile["rules"] = rules
|
||||
return profile
|
||||
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