faa076370d
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
90 lines
2.6 KiB
Python
90 lines
2.6 KiB
Python
"""Dnsmasq config persistence for Vacuum Wall.
|
|
|
|
Provides load/save for the declarative JSON config and upstream-management
|
|
helpers used by the sync bus and network handler. All mutation and
|
|
apply logic lives in daemon/handlers/dnsmasq.py.
|
|
"""
|
|
|
|
import logging
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from lib.common import deep_merge, ensure_dirs, load_json, save_json
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
|
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
|
|
|
DEFAULT_CFG: dict[str, Any] = {
|
|
"dhcp": {
|
|
"ranges": [],
|
|
"static_leases": [],
|
|
},
|
|
"dns": {
|
|
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
|
"domain": None,
|
|
"custom_records": [],
|
|
},
|
|
}
|
|
|
|
|
|
# ───────── config lifecycle ──────────────────────────────────────────
|
|
|
|
|
|
def get_config() -> dict[str, Any]:
|
|
"""Load current dnsmasq config from JSON state file.
|
|
|
|
Pure read — never writes or creates directories. Returns the in-memory
|
|
default when the file is missing; directories and the file are
|
|
materialized on the first ``save_config``.
|
|
"""
|
|
raw = load_json(CONFIG_PATH)
|
|
if not raw:
|
|
return deepcopy(DEFAULT_CFG)
|
|
return deep_merge(deepcopy(DEFAULT_CFG), raw)
|
|
|
|
|
|
def save_config(cfg: dict[str, Any]) -> None:
|
|
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
|
|
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
|
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
|
save_json(CONFIG_PATH, merged)
|
|
logger.info("dnsmasq config saved")
|
|
|
|
|
|
# ───────── upstream helpers ──────────────────────────────────────────
|
|
|
|
|
|
def set_upstreams(servers: list[str]) -> None:
|
|
"""Set the list of upstream DNS forwarders."""
|
|
cfg = get_config()
|
|
cfg["dns"]["upstreams"] = list(servers)
|
|
save_config(cfg)
|
|
logger.info("DNS upstreams set to %s", servers)
|
|
|
|
|
|
def set_domain(domain: str | None) -> None:
|
|
"""Set (or clear) the local DNS domain."""
|
|
cfg = get_config()
|
|
cfg["dns"]["domain"] = domain if domain else None
|
|
save_config(cfg)
|
|
logger.info("DNS domain set to '%s'", domain)
|
|
|
|
|
|
__all__ = [
|
|
"CONFIG_DIR",
|
|
"CONFIG_PATH",
|
|
"DATA_DIR",
|
|
"DEFAULT_CFG",
|
|
"FRAGMENTS_DIR",
|
|
"get_config",
|
|
"save_config",
|
|
"set_domain",
|
|
"set_upstreams",
|
|
]
|