"""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(): 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(): 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(): 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(): 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(): 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(): 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(): 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(): 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(): 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): 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(): 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): 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)