""" webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint. Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq. """ import logging from flask import Blueprint, jsonify, request from lib.dnsmasq import ( add_dns_record, add_static_lease, apply_config, get_config, get_lease_table, remove_dhcp_range, remove_dns_record, remove_static_lease, save_config, set_dhcp_range, ) logger = logging.getLogger(__name__) bp = Blueprint("dhcp", __name__) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _error(msg, code=400): return jsonify({"ok": False, "error": msg}), code def _ok(data=None): return jsonify({"ok": True, "data": data}) def _deep_merge(base, overrides): result = dict(base) for k, v in overrides.items(): if k in result and isinstance(result[k], dict) and isinstance(v, dict): result[k] = _deep_merge(result[k], v) else: result[k] = v return result # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @bp.route("/config", methods=["GET"]) def get_config_bp(): try: return _ok(get_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: save_config(body) return _ok(None) 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: current = get_config() merged = _deep_merge(current, body) save_config(merged) return _ok(None) 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: apply_config() 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: from lib.dnsmasq import get_status as dnsmasq_status return _ok(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: set_dhcp_range( iface if iface else "", start, end, lease_time=lease_time, ) logger.info("DHCP range added via API: %s-%s", start, end) return _ok(None) 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: remove_dhcp_range(iface, start, end) logger.info("DHCP range removed via API: %s-%s", start, end) return _ok(None) except RuntimeError as exc: logger.error("Failed to remove DHCP range: %s", exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- # Static leases # --------------------------------------------------------------------------- @bp.route("/leases", methods=["GET"]) def leases_bp(): try: return _ok(get_lease_table()) 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: add_static_lease(mac, ip, hostname) logger.info("Static lease added via API: %s -> %s", mac, ip) return _ok({"mac": mac, "ip": ip, "hostname": hostname}) 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): current = get_config() found = any( lease["mac"].lower() == mac.lower() for lease in current.get("dhcp", {}).get("static_leases", []) ) if not found: return _error(f"No static lease found for MAC '{mac}'", 404) try: remove_static_lease(mac) logger.info("Static lease removed via API: %s", mac) return _ok(None) except RuntimeError as exc: logger.error("Failed to remove static lease: %s", 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: add_dns_record(name, address, hostname) logger.info("DNS record added via API: %s -> %s", name, address) return _ok({"name": name, "address": address, "hostname": hostname}) 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): current = get_config() found = any( r["name"] == name for r in current.get("dns", {}).get("custom_records", []) ) if not found: return _error(f"No DNS record found for '{name}'", 404) try: remove_dns_record(name) logger.info("DNS record removed via API: %s", name) return _ok(None) except RuntimeError as exc: logger.error("Failed to remove DNS record: %s", exc) return _error(str(exc), 500)