This commit is contained in:
riglen
2026-04-21 16:56:06 +08:00
parent 05e0355e14
commit d5cfae22ea
14 changed files with 1404 additions and 15 deletions

View File

@@ -67,3 +67,81 @@ class ConfBaseConfig(BaseModel):
groups: list[ConfGroup] = Field(default_factory=list)
modules: list[ConfModule] = Field(default_factory=list)
builtins: list[ConfBuiltin] = Field(default_factory=list)
class ConfPolicyOverride(BaseModel):
module_key: str
policy: str
line_no: int
class ConfInlineRule(BaseModel):
value: str
line_no: int
class ConfProfileConfig(BaseModel):
name: str | None = None
enabled: bool = True
sources: list[str] = Field(default_factory=list)
include_modules: list[str] = Field(default_factory=list)
exclude_modules: list[str] = Field(default_factory=list)
include_builtins: list[str] = Field(default_factory=list)
override_policies: list[ConfPolicyOverride] = Field(default_factory=list)
prepend_rules: list[ConfInlineRule] = Field(default_factory=list)
append_rules: list[ConfInlineRule] = Field(default_factory=list)
class ConfResolvedSource(BaseModel):
key: str
display_name: str
source_type: str
value: str
enabled: bool = True
cache_ttl: int | None = None
options: dict[str, str] = Field(default_factory=dict)
class ConfResolvedModule(BaseModel):
key: str
path: str
policy: str
order: int
enabled: bool = True
class ConfResolvedBuiltin(BaseModel):
key: str
rule_line: str
policy: str
order: int
enabled: bool = True
class ConfResolvedGroup(BaseModel):
name: str
group_type: str
members: list[str] = Field(default_factory=list)
selector_key: str | None = None
url: str | None = None
interval: int | None = None
tolerance: int | None = None
raw: str
class ConfRuntimePlan(BaseModel):
profile_key: str
profile_name: str | None = None
selected_sources: list[ConfResolvedSource] = Field(default_factory=list)
selected_modules: list[ConfResolvedModule] = Field(default_factory=list)
selected_builtins: list[ConfResolvedBuiltin] = Field(default_factory=list)
selectors: dict[str, str] = Field(default_factory=dict)
groups: list[ConfResolvedGroup] = Field(default_factory=list)
prepend_rules: list[str] = Field(default_factory=list)
append_rules: list[str] = Field(default_factory=list)
mode: str | None = None
allow_lan: bool | None = None
log_level: str | None = None
ipv6: bool | None = None
append_userinfo_header: bool | None = None
userinfo_source_policy: str | None = None

View File

@@ -29,6 +29,7 @@ class Settings(BaseSettings):
sources_file: Path = CONFIG_DIR / "sources.yaml"
conf_base_file: Path = CONFIG_DIR / "conf" / "base.conf"
conf_profiles_dir: Path = CONFIG_DIR / "conf" / "profiles"
rules_dir: Path = CONFIG_DIR / "rules"
bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache"
fetch_cache_dir: Path = DATA_DIR / "fetch-cache"

View File

@@ -8,6 +8,10 @@ from fastapi.responses import Response
from app.config import get_settings
from app.models import RuleConfig, SourceConfig, SourceSnapshot
from app.services.bundle_cache import build_bundle_cache_key, load_bundle_cache, save_bundle_cache
from app.services.conf_config_store import load_conf_store
from app.services.conf_loader import ConfConfigError
from app.services.conf_profiles import build_conf_bundle_profile, build_conf_thin_profile, conf_source_to_source_config
from app.services.conf_runtime import build_conf_rule_lines, resolve_conf_runtime_plan
from app.services.loader import load_app_config
from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml
from app.services.rules import load_rule_text
@@ -69,6 +73,37 @@ def _rule_path(rule: RuleConfig):
return path
def _load_conf_profile_runtime(profile_key: str):
try:
base_config, profiles = load_conf_store()
except ConfConfigError as exc:
raise HTTPException(status_code=500, detail=f"invalid conf config: {exc}") from exc
profile = profiles.get(profile_key)
if profile is None:
raise HTTPException(status_code=404, detail="conf profile not found")
try:
plan = resolve_conf_runtime_plan(
profile_key=profile_key,
base_config=base_config,
profile_config=profile,
base_path=settings.conf_base_file,
)
except ConfConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return base_config, profile, plan
def _conf_source_items(plan) -> list[tuple[str, SourceConfig]]:
items: list[tuple[str, SourceConfig]] = []
for source in plan.selected_sources:
try:
items.append((source.key, conf_source_to_source_config(source, base_path=settings.conf_base_file)))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return items
async def _build_quota_headers(source_items: list[tuple[str, SourceConfig]]) -> dict[str, str]:
headers: dict[str, str] = {}
quota = await get_first_quota(source_items)
@@ -161,6 +196,58 @@ async def client_profile(client_type: str, request: Request, sources: str | None
return _yaml_response(content, request, headers=headers, filename=f"{client_type}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/rules/{profile_key}/{name}.yaml", methods=["GET", "HEAD"])
async def conf_rule_file(profile_key: str, name: str, request: Request) -> Response:
_, _, plan = _load_conf_profile_runtime(profile_key)
module = next((item for item in plan.selected_modules if item.key == name), None)
if module is None:
raise HTTPException(status_code=404, detail="conf rule not found")
path = (settings.conf_base_file.resolve().parent / module.path).resolve()
if not path.is_file():
raise HTTPException(status_code=404, detail="conf rule file missing")
content = load_rule_text(path)
return _yaml_response(content, request, filename=f"{profile_key}-{name}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/providers/{profile_key}/{name}.yaml", methods=["GET", "HEAD"])
async def conf_provider(profile_key: str, name: str, request: Request) -> Response:
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
source = next((item for item in source_items if item[0] == name), None)
if source is None:
raise HTTPException(status_code=404, detail="conf provider not found")
try:
document = await build_provider_document(source[0], source[1])
except Exception as exc: # noqa: BLE001
logger.exception("conf provider failed: profile=%s source=%s", profile_key, name)
raise HTTPException(status_code=502, detail=f"failed to build conf provider: {exc}") from exc
content = dump_provider_yaml(document)
headers = await _build_quota_headers([source])
return _yaml_response(content, request, headers=headers, filename=f"{profile_key}-{name}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/clients/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
async def conf_client_profile(client_type: str, profile_key: str, request: Request) -> Response:
client = app_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
content = dump_yaml(
build_conf_thin_profile(
client_type=client_type,
client=client,
plan=plan,
base_path=settings.conf_base_file,
base_url=_base_url(request),
public_path=(app_config.public_path or settings.public_path).strip("/"),
)
)
headers = {"profile-update-interval": str(client.provider_interval)}
headers.update(await _build_quota_headers(source_items))
return _yaml_response(content, request, headers=headers, filename=f"{client_type}-{profile_key}.yaml")
@app.api_route(PUBLIC_PREFIX + "/bundle/{client_type}.yaml", methods=["GET", "HEAD"])
async def bundle_profile(
client_type: str,
@@ -215,3 +302,58 @@ async def bundle_profile(
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"},
)
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/bundle/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
async def conf_bundle_profile(client_type: str, profile_key: str, request: Request, force_refresh: bool = Query(default=False)) -> Response:
client = app_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
cache_key = build_bundle_cache_key(
client_type=f"conf-{client_type}-{profile_key}",
source_names=[name for name, _ in source_items],
)
if not force_refresh:
cached = load_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
ttl_seconds=settings.bundle_cache_ttl_seconds,
)
if cached is not None:
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "HIT",
}
headers.update(cached.headers)
return _yaml_response(content=cached.content, request=request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")
try:
snapshots = await build_source_snapshots(source_items)
rules = build_conf_rule_lines(plan, base_path=settings.conf_base_file)
except Exception as exc: # noqa: BLE001
logger.exception("conf_bundle_profile failed: client=%s profile=%s", client_type, profile_key)
raise HTTPException(status_code=502, detail=f"failed to build conf bundle: {exc}") from exc
content = dump_yaml(
build_conf_bundle_profile(
client_type=client_type,
client=client,
plan=plan,
snapshots=snapshots,
rules=rules,
)
)
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "BYPASS" if force_refresh else "MISS",
}
headers.update(_quota_headers_from_snapshots(snapshots))
save_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
content=content,
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"},
)
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")

View 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

View File

@@ -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

View 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

View 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,
)