"""DHCP/DNS (dnsmasq) management API blueprint. Exposed at /api/dhcp/* and delegates all operations to vacuum-walld. """ import logging from flask import Blueprint, request from daemon.client import BadRequest, NotFound, delete, get, patch, post from webui.api.common import _error, _ok logger = logging.getLogger(__name__) bp = Blueprint("dhcp", __name__) # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @bp.route("/config", methods=["GET"]) def get_config_bp(): """GET /api/dhcp/config — Retrieve the current dnsmasq configuration. Returns: JSON response with the config or an error. """ try: return _ok(get("/dnsmasq/config")) except RuntimeError as exc: logger.error("Failed to read DHCP config: %s", exc) return _error(str(exc), 500) @bp.route("/config", methods=["POST"]) def post_config(): """POST /api/dhcp/config — Save a full replacement dnsmasq configuration. Args: request: JSON body containing the complete config object. Returns: JSON response with success status or an error. """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: post("/dnsmasq/config", body) return _ok(None) except BadRequest as exc: logger.info("DHCP config save rejected: %s", exc) return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to save DHCP config: %s", exc) return _error(str(exc), 500) @bp.route("/config", methods=["PATCH"]) def patch_config(): """PATCH /api/dhcp/config — Partially update the dnsmasq configuration. Args: request: JSON body containing the fields to update. Returns: JSON response with success status or an error. """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: patch("/dnsmasq/config", body) return _ok(None) except BadRequest as exc: logger.info("DHCP config patch rejected: %s", exc) return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to patch DHCP config: %s", exc) return _error(str(exc), 500) @bp.route("/apply", methods=["POST"]) def apply_bp(): """POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service.""" try: post("/dnsmasq/apply") logger.info("dnsmasq config applied via API") return _ok(None) except RuntimeError as exc: logger.error("Failed to apply dnsmasq config: %s", exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- # Status # --------------------------------------------------------------------------- @bp.route("/status", methods=["GET"]) def status_bp(): """GET /api/dhcp/status — Retrieve dnsmasq service status.""" try: return _ok(get("/dnsmasq/status")) except RuntimeError as exc: logger.error("Failed to get DHCP status: %s", exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- # DHCP ranges # --------------------------------------------------------------------------- @bp.route("/ranges", methods=["POST"]) def add_range_bp(): """POST /api/dhcp/ranges — Add a DHCP address range for an interface. Args: request: JSON body with `interface`, `start`, `end`, and optional `lease_time`. Returns: JSON response with success status or an error. """ body = request.get_json(silent=True) or {} iface = body.get("interface", "").strip() or None start = body.get("start", "").strip() end = body.get("end", "").strip() lease_time = body.get("lease_time", "12h") if not start or not end: return _error("'start' and 'end' are required", 400) try: post( "/dnsmasq/ranges/add", { "interface": iface or "", "start": start, "end": end, "lease_time": lease_time, }, ) logger.info("DHCP range added via API: %s-%s", start, end) return _ok(None) except BadRequest as exc: logger.info("Add DHCP range rejected: %s", exc) return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add DHCP range: %s", exc) return _error(str(exc), 500) @bp.route("/ranges", methods=["DELETE"]) def remove_range_bp(): """DELETE /api/dhcp/ranges — Remove a DHCP address range. Args: request: JSON body with `interface`, `start`, and `end`. Returns: JSON response with success status or an error. """ body = request.get_json(silent=True) or {} iface = body.get("interface", "").strip() or "" start = body.get("start", "").strip() end = body.get("end", "").strip() if not start or not end: return _error("'start' and 'end' are required", 400) try: delete( "/dnsmasq/ranges/remove", {"interface": iface, "start": start, "end": end} ) logger.info("DHCP range removed via API: %s-%s", start, end) return _ok(None) except NotFound as exc: logger.info("Remove DHCP range not found: %s", exc) return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove DHCP range: %s", exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- # Leases # --------------------------------------------------------------------------- @bp.route("/leases", methods=["GET"]) def leases_bp(): """GET /api/dhcp/leases — Retrieve the current DHCP lease table.""" try: return _ok(get("/dnsmasq/leases")) except RuntimeError as exc: logger.error("Failed to read lease table: %s", exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- # Static leases # --------------------------------------------------------------------------- @bp.route("/static-lease", methods=["POST"]) def add_static_lease_bp(): """POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address. Args: request: JSON body with `mac`, `ip`, and optional `hostname`. Returns: JSON response with lease details or an error. """ body = request.get_json(silent=True) or {} mac = body.get("mac", "").strip() ip = body.get("ip", "").strip() hostname = body.get("hostname") if not mac or not ip: return _error("'mac' and 'ip' are required", 400) try: post("/dnsmasq/static-lease/add", {"mac": mac, "ip": ip, "hostname": hostname}) logger.info("Static lease added via API: %s -> %s", mac, ip) return _ok({"mac": mac, "ip": ip, "hostname": hostname}) except BadRequest as exc: logger.info("Add static lease rejected: %s", exc) return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add static lease: %s", exc) return _error(str(exc), 500) @bp.route("/static-lease/", methods=["DELETE"]) def remove_static_lease_bp(mac): """DELETE /api/dhcp/static-lease/ — Remove a static DHCP lease by MAC address. Args: mac: MAC address of the static lease to remove. Returns: JSON response with success status or an error. """ try: delete("/dnsmasq/static-lease/remove", {"mac": mac}) logger.info("Static lease removed via API: %s", mac) return _ok(None) except NotFound as exc: logger.info("Static lease '%s' not found: %s", mac, exc) return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove static lease '%s': %s", mac, exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- # DNS records # --------------------------------------------------------------------------- @bp.route("/dns-record", methods=["POST"]) def add_dns_record_bp(): """POST /api/dhcp/dns-record — Add a DNS record. Args: request: JSON body with `name`, `address`, and optional `hostname`. Returns: JSON response with record details or an error. """ body = request.get_json(silent=True) or {} name = body.get("name", "").strip() address = body.get("address", "").strip() hostname = body.get("hostname") if not name or not address: return _error("'name' and 'address' are required", 400) try: post( "/dnsmasq/dns-record/add", {"name": name, "address": address, "hostname": hostname}, ) logger.info("DNS record added via API: %s -> %s", name, address) return _ok({"name": name, "address": address, "hostname": hostname}) except BadRequest as exc: logger.info("Add DNS record rejected: %s", exc) return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add DNS record: %s", exc) return _error(str(exc), 500) @bp.route("/dns-record/", methods=["DELETE"]) def remove_dns_record_bp(name): """DELETE /api/dhcp/dns-record/ — Remove a DNS record by name. Args: name: Name of the DNS record to remove. Returns: JSON response with success status or an error. """ try: delete("/dnsmasq/dns-record/remove", {"name": name}) logger.info("DNS record removed via API: %s", name) return _ok(None) except NotFound as exc: logger.info("DNS record '%s' not found: %s", name, exc) return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove DNS record '%s': %s", name, exc) return _error(str(exc), 500)