Files
vacuum-wall/lib/dnsmasq.py
T
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- 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.
2026-09-03 00:40:56 +00:00

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",
]