#!/usr/bin/env python3 """YAML config loader with merge hierarchy, hardware tier detection, and SIGHUP reload.""" import copy import os import signal import threading from pathlib import Path from typing import Any, Callable, Dict, List, Optional import yaml from utils.resource import get_hardware_tier CONFIG_DIR = "config" MERGE_ORDER = [ "hardware_tiers.yaml", "bigbrother.yaml", "modules.yaml", "stealth.yaml", ] def deep_merge(base: dict, override: dict) -> dict: result = copy.deepcopy(base) for key, value in override.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge(result[key], value) else: result[key] = copy.deepcopy(value) return result class ConfigLoader: def __init__( self, config_dir: str = CONFIG_DIR, cli_overrides: Optional[Dict[str, Any]] = None, auto_detect_tier: bool = True, ): self.config_dir = Path(config_dir) self.cli_overrides = cli_overrides or {} self.auto_detect_tier = auto_detect_tier self._config: Dict[str, Any] = {} self._lock = threading.Lock() self._reload_callbacks: List[Callable] = [] self._loaded = False def load(self) -> Dict[str, Any]: with self._lock: self._config = self._build_config() self._loaded = True return copy.deepcopy(self._config) def _build_config(self) -> Dict[str, Any]: merged: Dict[str, Any] = {} for filename in MERGE_ORDER: filepath = self.config_dir / filename if filepath.exists(): try: with open(filepath, "r") as f: data = yaml.safe_load(f) or {} merged = deep_merge(merged, data) except (yaml.YAMLError, IOError): continue # Apply hardware tier defaults if self.auto_detect_tier: merged = self._apply_tier_limits(merged) # Apply CLI overrides last (highest priority) if self.cli_overrides: merged = deep_merge(merged, self.cli_overrides) # Validate self._validate(merged) return merged def _apply_tier_limits(self, config: dict) -> dict: tier = config.get("device", {}).get("platform", "auto") if tier == "auto": tier = get_hardware_tier() config.setdefault("device", {})["platform"] = tier # Extract tier-specific limits from hardware_tiers section tiers = config.pop("hardware_tiers", {}) tier_config = tiers.get(tier, tiers.get("generic", {})) if tier_config: config = deep_merge(config, tier_config) # Apply well-known tier constraints if tier == "pi_zero": config.setdefault("capture", {})["pcap_compression_level"] = 3 config.setdefault("security", {})["encryption_key_derive"] = "pbkdf2" # Limit passive modules limits = config.setdefault("limits", {}) limits.setdefault("max_passive_modules", 4) limits.setdefault("max_active_modules", 0) elif tier == "opi_zero3": config.setdefault("capture", {})["pcap_compression_level"] = 19 config.setdefault("security", {})["encryption_key_derive"] = "argon2id" limits = config.setdefault("limits", {}) limits.setdefault("max_passive_modules", 16) limits.setdefault("max_active_modules", 6) else: config.setdefault("capture", {})["pcap_compression_level"] = 19 config.setdefault("security", {})["encryption_key_derive"] = "argon2id" limits = config.setdefault("limits", {}) limits.setdefault("max_passive_modules", 16) limits.setdefault("max_active_modules", -1) # unlimited return config def _validate(self, config: dict) -> None: device = config.get("device", {}) platform = device.get("platform", "") if platform and platform not in ("auto", "opi_zero3", "pi_zero", "generic"): raise ValueError(f"Invalid platform: {platform}") capture = config.get("capture", {}) comp_level = capture.get("pcap_compression_level") if comp_level is not None and not (1 <= comp_level <= 22): raise ValueError(f"Invalid compression level: {comp_level}") bettercap = config.get("bettercap", {}) port = bettercap.get("api_port") if port is not None and not (1 <= port <= 65535): raise ValueError(f"Invalid bettercap API port: {port}") security = config.get("security", {}) kdf = security.get("encryption_key_derive") if kdf and kdf not in ("argon2id", "pbkdf2"): raise ValueError(f"Invalid key derivation method: {kdf}") def get(self, key_path: str, default: Any = None) -> Any: with self._lock: keys = key_path.split(".") current = self._config for k in keys: if isinstance(current, dict) and k in current: current = current[k] else: return default return copy.deepcopy(current) def reload(self) -> Dict[str, Any]: config = self.load() for cb in self._reload_callbacks: try: cb(config) except Exception: pass return config def on_reload(self, callback: Callable): self._reload_callbacks.append(callback) def register_sighup(self): def _handler(signum, frame): self.reload() signal.signal(signal.SIGHUP, _handler) @property def config(self) -> Dict[str, Any]: with self._lock: if not self._loaded: return self.load() return copy.deepcopy(self._config) def load_config( config_dir: str = CONFIG_DIR, cli_overrides: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: loader = ConfigLoader(config_dir=config_dir, cli_overrides=cli_overrides) return loader.load()