import io import os import re import time from collections import defaultdict from datetime import timedelta from flask import Flask, jsonify, request, session, send_file, Response import bind_manager as bm import metrics app = Flask(__name__) # Password gate: if WEBUI_PASSWORD is empty/not set, authentication is disabled. WEBUI_PASSWORD = os.environ.get("WEBUI_PASSWORD", "").strip() app.secret_key = os.environ.get("SECRET_KEY", "bind9-webui-dev-secret-change-me") # "Remember me" sessions auto-logout after 30 minutes. app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=30) # Brute-force protection: N failed logins per IP within the window = temporary lockout. MAX_LOGIN_FAILURES = 5 LOGIN_FAIL_WINDOW = 900 # seconds LOGIN_LOCKOUT = 900 # seconds _login_failures = defaultdict(list) # ip -> [timestamps] _login_locked_until = {} # ip -> timestamp def _client_ip(): return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "unknown" def _lockout_remaining(ip): until = _login_locked_until.get(ip, 0) remaining = int(until - time.time()) if remaining > 0: return remaining if until: _login_locked_until.pop(ip, None) return 0 def _record_failure(ip): now = time.time() _login_failures[ip] = [t for t in _login_failures[ip] if now - t < LOGIN_FAIL_WINDOW] _login_failures[ip].append(now) if len(_login_failures[ip]) >= MAX_LOGIN_FAILURES: _login_locked_until[ip] = now + LOGIN_LOCKOUT _login_failures.pop(ip, None) def _clear_failures(ip): _login_failures.pop(ip, None) _login_locked_until.pop(ip, None) def is_authenticated(): return bool(session.get("auth")) def _ok(data=None, msg=None): r = {"ok": True} if data is not None: r["data"] = data if msg: r["message"] = msg return jsonify(r) def _err(msg, code=400): return jsonify({"ok": False, "error": str(msg)}), code _ZONE_NAME_RE = re.compile(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.?$") def _valid_zone_name(name): if len(name) > 253 or ".." in name: return False return bool(_ZONE_NAME_RE.match(name)) and "." in name.rstrip(".") # Health/readiness and the API-info root live outside the auth gate. OPEN_PATHS = ("/healthz", "/readyz", "/") @app.before_request def record_metrics(): metrics.record_request_started() @app.after_request def record_status(response): metrics.record_request_finished(response.status_code) return response @app.before_request def protect_endpoints(): # Let unmatched routes fall through to Flask's 404, not the auth gate. if request.url_rule is None: return None # Only enforce when a password is configured. if not WEBUI_PASSWORD: return None if request.path in OPEN_PATHS: return None # Allow the auth endpoints only. if request.endpoint in ("api_login", "api_session"): return None if not is_authenticated(): return _err("Unauthorized", code=401) return None @app.route("/") def index(): return jsonify({ "service": "bind9-webui", "api_only": True, "auth_required": bool(WEBUI_PASSWORD), "hint": "This is the API backend. Manage BIND9 from the webui facade.", }) # ── Probes & metrics ───────────────────────────────────────────────────── @app.route("/healthz") def healthz(): return Response("ok\n", mimetype="text/plain") @app.route("/readyz") def readyz(): if metrics.ready(): return Response("ok\n", mimetype="text/plain") return Response("not ready\n", mimetype="text/plain", status=503) @app.route("/metrics") def api_metrics(): return Response(metrics.metrics_text(), mimetype="text/plain; version=0.0.4; charset=utf-8") # ── Authentication ────────────────────────────────────────────────────────── @app.route("/api/session") def api_session(): if not WEBUI_PASSWORD: return _ok({"auth": True, "auth_required": False}) return _ok({"auth": is_authenticated(), "auth_required": True}) @app.route("/api/login", methods=["POST"]) def api_login(): if not WEBUI_PASSWORD: return _err("Authentication is not enabled") ip = _client_ip() remaining = _lockout_remaining(ip) if remaining > 0: return _err(f"Too many failed attempts. Try again in {int(remaining / 60)} minute(s).", code=429) data = request.json or {} password = data.get("password", "") if password != WEBUI_PASSWORD: _record_failure(ip) return _err("Incorrect password", code=401) _clear_failures(ip) session["auth"] = True # Remember me -> persistent cookie that auto-expires in 30 minutes. session.permanent = bool(data.get("remember", False)) session.permanent_session_lifetime = app.config["PERMANENT_SESSION_LIFETIME"] return _ok({"auth": True, "auth_required": True}) @app.route("/api/logout", methods=["POST"]) def api_logout(): session.clear() return _ok(msg="Logged out") # ── Dashboard ─────────────────────────────────────────────────────────────── @app.route("/api/status") def api_status(): try: return _ok(bm.get_status()) except Exception as e: return _err(e) @app.route("/api/status/structured") def api_status_structured(): try: return _ok(bm.get_status_structured()) except Exception as e: return _err(e) @app.route("/api/zones") def api_zones(): return _ok(bm.get_zones()) @app.route("/api/zone/") def api_zone_detail(name): try: text = bm.get_zone_file(name) path = bm.get_zone_path(name) zones = bm.get_zones() zone = next((z for z in zones if z["name"] == name), None) source = zone["source"] if zone else "unknown" protected = zone.get("protected", False) if zone else False records, soa = bm.parse_zone_records(text) return _ok({"zone": name, "records": records, "soa": soa, "raw": text, "path": path, "source": source, "protected": protected}) except Exception as e: return _err(e) @app.route("/api/zone//file", methods=["PUT"]) def api_zone_update_file(name): data = request.json or {} content = data.get("content", "") try: path = bm.write_zone_file(name, content) return _ok(msg=f"Zone file saved to {path}") except Exception as e: return _err(e) @app.route("/api/zone//source", methods=["POST"]) def api_zone_move_source(name): data = request.json or {} target = data.get("target", "default") try: bm.move_zone_source(name, target) return _ok(msg=f"Zone {name} moved to {target} config") except Exception as e: return _err(e) @app.route("/api/map-hosts", methods=["POST"]) def api_map_hosts(): data = request.json or {} text = data.get("text", "") if not text: return _err("No host lines provided") try: return _ok(bm.map_hosts(text)) except Exception as e: return _err(e) # ── Zone CRUD ─────────────────────────────────────────────────────────────── @app.route("/api/zone", methods=["POST"]) def api_zone_create(): data = request.json or {} name = data.get("name", "").strip() if not name: return _err("Zone name required") if not _valid_zone_name(name): return _err("Invalid zone name (letters, digits, dots, hyphens only)") body = data.get("body") if body is not None and body.strip(): check = bm.validate_zone_body(name, body) if not check["valid"]: return _err(f"Zone file invalid:\n{(check['error'] or check['output']).strip()}") try: bm.add_zone( name, zone_type=data.get("type", "master"), records=data.get("records"), body=body, ttl=int(data.get("ttl", 3600) or 3600), ) return _ok(msg=f"Zone {name} created") except Exception as e: return _err(e) @app.route("/api/zone/preview", methods=["POST"]) def api_zone_preview(): data = request.json or {} name = data.get("name", "").strip() if not name: return _err("Zone name required") if not _valid_zone_name(name): return _err("Invalid zone name (letters, digits, dots, hyphens only)") try: body = bm.build_zone_file( name, data.get("records", []), soa={"primary_ns": f"ns1.{name}.", "admin_email": f"admin.{name}"}, ttl=int(data.get("ttl", 3600) or 3600), ) return _ok(data={"body": body}) except Exception as e: return _err(e) @app.route("/api/zone/", methods=["DELETE"]) def api_zone_delete(name): try: bm.remove_zone(name) return _ok(msg=f"Zone {name} removed") except Exception as e: return _err(e) # ── Records ───────────────────────────────────────────────────────────────── @app.route("/api/zone//records", methods=["PUT"]) def api_zone_update_records(name): data = request.json or {} records = data.get("records", []) try: bm.update_zone_records(name, records) return _ok(msg="Records updated") except Exception as e: return _err(e) @app.route("/api/zone//record", methods=["POST"]) def api_record_add(name): data = request.json or {} required = ["name", "type", "value"] for f in required: if f not in data: return _err(f"Missing field: {f}") try: bm.add_record(name, data["name"], data["type"], data["value"], ttl=data.get("ttl", 3600)) return _ok(msg="Record added") except Exception as e: return _err(e) @app.route("/api/zone//record/", methods=["DELETE"]) def api_record_delete(name, idx): try: bm.remove_record(name, idx) return _ok(msg="Record removed") except Exception as e: return _err(e) # ── Configuration ─────────────────────────────────────────────────────────── CONF_FILES = { "named.conf": bm.NAMED_CONF, "named.conf.options": bm.NAMED_CONF_OPTIONS, "named.conf.local": bm.NAMED_CONF_LOCAL, "named.conf.default-zones": bm.NAMED_CONF_DEFAULT_ZONES, } @app.route("/api/config/files") def api_config_files(): return _ok(list(CONF_FILES.keys())) @app.route("/api/config/file/") def api_config_get(name): path = CONF_FILES.get(name) if path is None: return _err("Unknown config file") return _ok(bm.read_conf_file(path)) @app.route("/api/config/file/", methods=["PUT"]) def api_config_set(name): path = CONF_FILES.get(name) if path is None: return _err("Unknown config file") data = request.json or {} content = data.get("content", "") try: bm.write_conf_file(path, content) return _ok(msg=f"{name} saved") except Exception as e: return _err(e) @app.route("/api/config/check") def api_config_check(): return _ok(bm.check_config()) @app.route("/api/zone//check") def api_zone_check(name): try: return _ok(bm.check_zone(name)) except Exception as e: return _err(e) # ── Logs ──────────────────────────────────────────────────────────────────── @app.route("/api/logs") def api_logs(): lines = request.args.get("lines", 100, type=int) query = request.args.get("query", "") return _ok(bm.get_logs(lines=lines, query=query)) # ── Backup / Restore ──────────────────────────────────────────────────────── @app.route("/api/backup") def api_backup(): try: data = bm.backup_data() ts = time.strftime("%Y-%m-%d_%H%M%S") return send_file( io.BytesIO(data), mimetype="application/gzip", as_attachment=True, download_name=f"bind9-backup-{ts}.tar.gz", ) except Exception as e: return _err(e) @app.route("/api/restore", methods=["POST"]) def api_restore(): f = request.files.get("file") if not f: return _err("No backup file uploaded") try: res = bm.restore_backup(f.read()) return _ok(res, msg="Backup restored") except Exception as e: return _err(e) # ── Dig ───────────────────────────────────────────────────────────────────── @app.route("/api/dig", methods=["POST"]) def api_dig(): data = request.json or {} query = data.get("q", "").strip() rtype = (data.get("type") or "A").strip() server = data.get("server", "").strip() or None if not query: return _err("Query required (e.g. example.com or 8.8.8.8)") try: return _ok(bm.dig(query, rtype=rtype, server=server)) except Exception as e: return _err(e) # ── rndc Controls ─────────────────────────────────────────────────────────── @app.route("/api/control/reload", methods=["POST"]) def api_reload(): try: return _ok(bm.reload_config()) except Exception as e: return _err(e) @app.route("/api/control/flush", methods=["POST"]) def api_flush(): try: return _ok(bm.flush_cache()) except Exception as e: return _err(e) @app.route("/api/control/stats", methods=["POST"]) def api_stats(): try: return _ok(bm.get_stats()) except Exception as e: return _err(e) @app.route("/api/control/querylog", methods=["POST"]) def api_querylog(): data = request.json or {} try: return _ok(bm.query_log(data.get("enable", True))) except Exception as e: return _err(e) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)