Files
vacuum-wall/webui/api/dhcp.py
T
mteehan 65741644a3 Fix dashboard template bugs, acme date parsing, wireguard sudoers match, and stale docs
- dashboard.html: Fix zones, leases, wg_status, cert key names, add services var
- server.py: Pass services to dashboard template via _get_service_status()
- lib/acme.py: Fix dead third date format (%Y%m%d%H%M%z) using astimezone(UTC)
- lib/wireguard.py: Add -- separator to cp command to match sudoers rule
- lib/nginx.py: Replace shallow dict.copy() with {**...} for DEFAULT_SSL
- AGENTS.md: Update test count 149 -> 154
- docs/api.md: Rename cert field expiry -> expires_at
2026-05-08 19:11:54 +00:00

182 lines
5.0 KiB
Python

"""
webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint.
Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
"""
from flask import Blueprint, jsonify, request
from lib.dnsmasq import (
add_dns_record,
add_static_lease,
apply_config,
get_config,
get_lease_table,
remove_dns_record,
remove_static_lease,
save_config,
)
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:
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:
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:
return _error(str(exc), 500)
@bp.route("/apply", methods=["POST"])
def apply_bp():
try:
apply_config()
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Leases
# ---------------------------------------------------------------------------
@bp.route("/leases", methods=["GET"])
def leases_bp():
try:
return _ok(get_lease_table())
except RuntimeError as 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)
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/static-lease", methods=["DELETE"])
def remove_static_lease_bp():
mac = request.args.get("mac", "").strip()
if not mac:
return _error("Query parameter 'mac' is required", 400)
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)
return _ok(None)
except RuntimeError as 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)
return _ok({"name": name, "address": address, "hostname": hostname})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/dns-record", methods=["DELETE"])
def remove_dns_record_bp():
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
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)
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)