"""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 daemon.iface import ( DELETE_DNSMASQ_DNS_RECORD_REMOVE, DELETE_DNSMASQ_RANGES_REMOVE, DELETE_DNSMASQ_STATIC_LEASE_REMOVE, GET_DNSMASQ_CONFIG, GET_DNSMASQ_LEASES, GET_DNSMASQ_STATUS, PATCH_DNSMASQ_CONFIG, POST_DNSMASQ_APPLY, POST_DNSMASQ_CONFIG, POST_DNSMASQ_DNS_RECORD_ADD, POST_DNSMASQ_DOMAIN, POST_DNSMASQ_RANGES_ADD, POST_DNSMASQ_STATIC_LEASE_ADD, ) 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(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(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(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(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(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( 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( 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(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( 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(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( 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) # --------------------------------------------------------------------------- # DNS domain # --------------------------------------------------------------------------- @bp.route("/domain", methods=["POST"]) def set_domain_bp(): """POST /api/dhcp/domain — Set or clear the DNS search domain. Args: request: JSON body with `domain` field (string or null to clear). 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(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")}) logger.info("DNS domain updated via API: %s", body.get("domain")) return _ok(None) except BadRequest as exc: logger.info("Set DNS domain rejected: %s", exc) return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to set DNS domain: %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(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)