Initial commit: SSL proxy / firewall appliance
Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP, WireGuard, and ACME certificate management.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
webui/api/certs.py - ACME certificate management API blueprint.
|
||||
|
||||
Exposed at /api/certs/* and delegates to lib.acme.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.acme import (
|
||||
get_cert_info,
|
||||
issue,
|
||||
list_certs,
|
||||
remove,
|
||||
renew,
|
||||
set_email,
|
||||
)
|
||||
|
||||
bp = Blueprint("certs", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Certificate listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/list", methods=["GET"])
|
||||
def list_certs_bp():
|
||||
try:
|
||||
return _ok(list_certs())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["GET"])
|
||||
def cert_details(domain):
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return _ok(info)
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/issue", methods=["POST"])
|
||||
def issue_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
webroot = body.get("webroot")
|
||||
standalone = body.get("standalone", False)
|
||||
try:
|
||||
result = issue(domain, webroot=webroot, standalone=standalone)
|
||||
if result.get("success"):
|
||||
return _ok(result)
|
||||
return jsonify(
|
||||
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
|
||||
), 400
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renew
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/<domain>/renew", methods=["POST"])
|
||||
def renew_bp(domain):
|
||||
try:
|
||||
result = renew(domain)
|
||||
if result.get("success"):
|
||||
return _ok(result)
|
||||
return jsonify(
|
||||
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
|
||||
), 400
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["DELETE"])
|
||||
def remove_bp(domain):
|
||||
try:
|
||||
remove(domain)
|
||||
return _ok({"domain": domain})
|
||||
except (RuntimeError, FileNotFoundError) as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contact email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/email", methods=["POST"])
|
||||
def set_email_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
return _error("'email' is required", 400)
|
||||
try:
|
||||
set_email(email)
|
||||
return _ok({"email": email})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
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):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
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(body)
|
||||
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(merged)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
try:
|
||||
apply_config()
|
||||
return _ok({"message": "dnsmasq configuration applied"})
|
||||
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)
|
||||
try:
|
||||
remove_static_lease(mac)
|
||||
return _ok({"mac": mac})
|
||||
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)
|
||||
try:
|
||||
remove_dns_record(name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
|
||||
|
||||
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.firewall import (
|
||||
add_forward_port,
|
||||
add_rich_rule,
|
||||
create_zone,
|
||||
delete_zone,
|
||||
get_active_zones,
|
||||
get_available_zones,
|
||||
get_interfaces,
|
||||
get_rich_rules,
|
||||
get_services,
|
||||
get_zone_info,
|
||||
remove_forward_port,
|
||||
remove_rich_rule,
|
||||
set_masquerade,
|
||||
set_zone_interfaces,
|
||||
set_zone_services,
|
||||
)
|
||||
|
||||
bp = Blueprint("firewall", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zones
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones", methods=["GET"])
|
||||
def list_zones():
|
||||
try:
|
||||
active = get_active_zones()
|
||||
available = get_available_zones()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"data": {
|
||||
"active": active,
|
||||
"available": available,
|
||||
},
|
||||
}
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones/<name>", methods=["GET"])
|
||||
def zone_details(name):
|
||||
try:
|
||||
info = get_zone_info(name)
|
||||
return jsonify({"ok": True, "data": info})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones", methods=["POST"])
|
||||
def create_zone_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone_name = body.get("name", "").strip()
|
||||
target = body.get("target", "default").strip() or "default"
|
||||
if not zone_name:
|
||||
return _error("Zone name is required", 400)
|
||||
try:
|
||||
if zone_name in get_available_zones():
|
||||
return _error(f"Zone '{zone_name}' already exists", 400)
|
||||
create_zone(zone_name, target)
|
||||
return _ok({"name": zone_name, "target": target})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones/<name>", methods=["DELETE"])
|
||||
def delete_zone_bp(name):
|
||||
try:
|
||||
available = get_available_zones()
|
||||
if name not in available:
|
||||
return _error(f"Zone '{name}' does not exist", 404)
|
||||
delete_zone(name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone interfaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones/<name>/interfaces", methods=["POST"])
|
||||
def set_zone_interfaces_bp(name):
|
||||
body = request.get_json(silent=True) or {}
|
||||
interfaces = body.get("interfaces", [])
|
||||
if not isinstance(interfaces, list):
|
||||
return _error("'interfaces' must be a list", 400)
|
||||
try:
|
||||
set_zone_interfaces(name, interfaces)
|
||||
return _ok({"zone": name, "interfaces": interfaces})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone services
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones/<name>/services", methods=["POST"])
|
||||
def set_zone_services_bp(name):
|
||||
body = request.get_json(silent=True) or {}
|
||||
services = body.get("services", [])
|
||||
if not isinstance(services, list):
|
||||
return _error("'services' must be a list", 400)
|
||||
try:
|
||||
set_zone_services(name, services)
|
||||
return _ok({"zone": name, "services": services})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Available services and interfaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/services", methods=["GET"])
|
||||
def list_services():
|
||||
try:
|
||||
return _ok(get_services())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/interfaces", methods=["GET"])
|
||||
def list_interfaces():
|
||||
try:
|
||||
return _ok(get_interfaces())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rich rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/rich-rules", methods=["POST"])
|
||||
def add_rich_rule_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
rule = body.get("rule", "").strip()
|
||||
if not zone or not rule:
|
||||
return _error("Both 'zone' and 'rule' are required", 400)
|
||||
try:
|
||||
add_rich_rule(zone, rule)
|
||||
return _ok({"zone": zone, "rule": rule})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/rich-rules", methods=["DELETE"])
|
||||
def remove_rich_rule_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
rule = body.get("rule", "").strip()
|
||||
if not zone or not rule:
|
||||
return _error("Both 'zone' and 'rule' are required", 400)
|
||||
try:
|
||||
remove_rich_rule(zone, rule)
|
||||
return _ok({"zone": zone, "rule": rule})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
||||
def list_rich_rules(zone):
|
||||
try:
|
||||
rules = get_rich_rules(zone)
|
||||
return _ok(rules)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masquerade (NAT)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/masquerade", methods=["POST"])
|
||||
def set_masquerade_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
enable = body.get("enable")
|
||||
if not zone or enable is None:
|
||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
||||
try:
|
||||
set_masquerade(zone, bool(enable))
|
||||
return _ok({"zone": zone, "masquerade": bool(enable)})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/forward-port", methods=["POST"])
|
||||
def add_forward_port_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
port = body.get("port")
|
||||
proto = body.get("proto", "").strip()
|
||||
toaddr = body.get("toaddr")
|
||||
toport = body.get("toport")
|
||||
if not zone or port is None or not proto:
|
||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
||||
try:
|
||||
add_forward_port(
|
||||
zone,
|
||||
int(port),
|
||||
proto,
|
||||
toaddr=str(toaddr) if toaddr else None,
|
||||
toport=int(toport) if toport else None,
|
||||
)
|
||||
return _ok({"zone": zone, "port": int(port), "proto": proto})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
return _error(str(exc), code)
|
||||
|
||||
|
||||
@bp.route("/forward-port", methods=["DELETE"])
|
||||
def remove_forward_port_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
port = body.get("port")
|
||||
proto = body.get("proto", "").strip()
|
||||
toaddr = body.get("toaddr")
|
||||
toport = body.get("toport")
|
||||
if not zone or port is None or not proto:
|
||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
||||
try:
|
||||
remove_forward_port(
|
||||
zone,
|
||||
int(port),
|
||||
proto,
|
||||
toaddr=str(toaddr) if toaddr else None,
|
||||
toport=int(toport) if toport else None,
|
||||
)
|
||||
return _ok({"zone": zone, "port": int(port), "proto": proto})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
return _error(str(exc), code)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
webui/api/proxy.py - Nginx proxy domain management API blueprint.
|
||||
|
||||
Exposed at /api/proxy/* and delegates to lib.nginx.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.nginx import (
|
||||
add_domain,
|
||||
apply,
|
||||
get_config,
|
||||
get_domains,
|
||||
remove_domain,
|
||||
set_management_proxy,
|
||||
test_config,
|
||||
update_domain,
|
||||
)
|
||||
|
||||
bp = Blueprint("proxy", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/domains", methods=["GET"])
|
||||
def list_domains():
|
||||
try:
|
||||
return _ok(get_domains())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains", methods=["POST"])
|
||||
def add_domain_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
if not backend_host:
|
||||
return _error("'backend_host' is required", 400)
|
||||
if backend_port is None:
|
||||
return _error("'backend_port' is required", 400)
|
||||
try:
|
||||
add_domain(
|
||||
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
|
||||
)
|
||||
return _ok({"domain": domain})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["GET"])
|
||||
def domain_details(domain):
|
||||
try:
|
||||
cfg = get_config()
|
||||
entry = cfg.get("domains", {}).get(domain)
|
||||
if entry is None:
|
||||
return _error(f"Domain '{domain}' not found", 404)
|
||||
return _ok({"domain": domain, **entry})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["PUT"])
|
||||
def update_domain_bp(domain):
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not body:
|
||||
return _error("Request body must be a JSON object with fields to update", 400)
|
||||
try:
|
||||
update_domain(domain, **body)
|
||||
return _ok({"domain": domain})
|
||||
except KeyError as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["DELETE"])
|
||||
def remove_domain_bp(domain):
|
||||
try:
|
||||
cfg = get_config()
|
||||
if domain not in cfg.get("domains", {}):
|
||||
return _error(f"Domain '{domain}' not found", 404)
|
||||
remove_domain(domain)
|
||||
return _ok({"domain": domain})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply / test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
try:
|
||||
apply()
|
||||
return _ok({"message": "nginx configuration applied and reloaded"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/test", methods=["POST"])
|
||||
def test_bp():
|
||||
try:
|
||||
ok, message = test_config()
|
||||
if ok:
|
||||
return _ok({"passed": True, "message": message})
|
||||
return jsonify({"ok": False, "error": message, "passed": False}), 400
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/management", methods=["POST"])
|
||||
def management_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
|
||||
flask_port = body.get("flask_port", 9090)
|
||||
auth_user = body.get("auth_user")
|
||||
auth_pass = body.get("auth_pass")
|
||||
try:
|
||||
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
|
||||
return _ok({"domain": domain})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
return _error(str(exc), code)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
webui/api/wireguard.py - WireGuard tunnel management API blueprint.
|
||||
|
||||
Exposed at /api/wireguard/* and delegates to lib.wireguard.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.wireguard import (
|
||||
add_peer,
|
||||
apply,
|
||||
down,
|
||||
generate_client_conf,
|
||||
get_config,
|
||||
get_peer_status,
|
||||
get_peers,
|
||||
initialize,
|
||||
remove_peer,
|
||||
save_config,
|
||||
status,
|
||||
)
|
||||
|
||||
bp = Blueprint("wireguard", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def get_config_bp():
|
||||
try:
|
||||
cfg = get_config()
|
||||
safe = dict(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
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)
|
||||
safe = dict(body)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply / down
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
try:
|
||||
apply()
|
||||
return _ok({"message": "WireGuard configuration applied and tunnel brought up"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/down", methods=["POST"])
|
||||
def down_bp():
|
||||
try:
|
||||
down()
|
||||
return _ok({"message": "WireGuard tunnel brought down"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
def status_bp():
|
||||
try:
|
||||
return _ok(status())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialize (first-time setup)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/initialize", methods=["POST"])
|
||||
def initialize_bp():
|
||||
try:
|
||||
cfg = initialize()
|
||||
safe = dict(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peer management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/add-peer", methods=["POST"])
|
||||
def add_peer_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("'name' is required", 400)
|
||||
try:
|
||||
peer = add_peer(
|
||||
name=name,
|
||||
endpoint=body.get("endpoint"),
|
||||
allowed_ips=body.get("allowed_ips", []),
|
||||
persistent_keepalive=body.get("persistent_keepalive"),
|
||||
preshared_key=body.get("preshared_key"),
|
||||
)
|
||||
safe = dict(peer)
|
||||
safe.pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/remove-peer", methods=["DELETE"])
|
||||
def remove_peer_bp():
|
||||
name = request.args.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("Query parameter 'name' is required", 400)
|
||||
try:
|
||||
cfg = get_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
return _error(f"Peer '{name}' not found", 404)
|
||||
remove_peer(name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/peers", methods=["GET"])
|
||||
def peers_bp():
|
||||
try:
|
||||
return _ok(get_peers())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/peer-status", methods=["GET"])
|
||||
def peer_status_bp():
|
||||
try:
|
||||
return _ok(get_peer_status())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client config generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/generate-client", methods=["POST"])
|
||||
def generate_client_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("Field 'name' is required", 400)
|
||||
try:
|
||||
cfg = get_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
return _error(f"Peer '{name}' not found", 404)
|
||||
server_endpoint = body.get("server_endpoint", "")
|
||||
server_pubkey = cfg["interface"].get("public_key", "")
|
||||
if not server_endpoint:
|
||||
_ = cfg["interface"].get("listen_port", 51820)
|
||||
# Can't auto-derive public IP; ask user to provide it
|
||||
return _error(
|
||||
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
|
||||
)
|
||||
conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
|
||||
return _ok({"config": conf_text, "name": name})
|
||||
except (KeyError, ValueError, RuntimeError) as exc:
|
||||
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
|
||||
return _error(str(exc), code)
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
server.py - Vacuum Wall management WebUI entry point.
|
||||
|
||||
Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
|
||||
and enforces basic authentication before proxying to this port.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, render_template
|
||||
|
||||
from lib.acme import get_email, list_certs
|
||||
from lib.dnsmasq import get_config as dnsmasq_config
|
||||
from lib.dnsmasq import get_lease_table
|
||||
from lib.dnsmasq import get_status as dnsmasq_status
|
||||
from lib.firewall import get_active_zones, get_interfaces, get_zone_info
|
||||
from lib.nginx import get_config as nginx_config
|
||||
from lib.nginx import get_domains
|
||||
from lib.wireguard import get_config as wg_config
|
||||
from lib.wireguard import status as wg_status
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp as firewall_bp
|
||||
from webui.api.proxy import bp as proxy_bp
|
||||
from webui.api.wireguard import bp as wireguard_bp
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
||||
|
||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
||||
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 custom filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.template_filter("timestamp")
|
||||
def timestamp_filter(value):
|
||||
"""Convert an ISO timestamp string to a human-readable date."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.template_filter("bytes")
|
||||
def bytes_filter(value):
|
||||
"""Format a byte count to a human-readable string (KB / MB / GB)."""
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if num < 0:
|
||||
return "0 B"
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(num) < 1024:
|
||||
return f"{num:.1f} {unit}"
|
||||
num /= 1024
|
||||
return f"{num:.1f} PB"
|
||||
|
||||
|
||||
@app.template_filter("duration")
|
||||
def duration_filter(value):
|
||||
"""Format a duration in seconds to a human-readable string."""
|
||||
try:
|
||||
total = int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if total < 0:
|
||||
return "0s"
|
||||
parts = []
|
||||
days, remainder = divmod(total, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if days:
|
||||
parts.append(f"{days}d")
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{seconds}s")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@app.template_filter("json_pretty")
|
||||
def json_pretty_filter(value):
|
||||
"""Pretty-print a JSON-serialisable value for debug displays."""
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, indent=2, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safely(fn, default=None):
|
||||
"""Call *fn* and return *default* on any exception."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as exc:
|
||||
logger.warning("WebUI data load failed: %s", exc)
|
||||
return default
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
active_zones = _safely(get_active_zones, {})
|
||||
interfaces = _safely(get_interfaces, [])
|
||||
dnsmasq = _safely(dnsmasq_status, {})
|
||||
domains = _safely(get_domains, [])
|
||||
certs = _safely(list_certs, [])
|
||||
wg = _safely(wg_status, {})
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
active_zones=active_zones,
|
||||
interfaces=interfaces,
|
||||
dnsmasq=dnsmasq,
|
||||
domains=domains,
|
||||
certs=certs,
|
||||
wg_status=wg,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/interfaces")
|
||||
def interfaces_page():
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=_safely(get_interfaces, []),
|
||||
active_zones=_safely(get_active_zones, {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/zones")
|
||||
def zones_page():
|
||||
zones = {}
|
||||
for name in _safely(get_active_zones, {}):
|
||||
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
|
||||
return render_template(
|
||||
"zones.html",
|
||||
zones=zones,
|
||||
interfaces=_safely(get_interfaces, []),
|
||||
services=_safely(
|
||||
lambda: __import__(
|
||||
"lib.firewall", fromlist=["get_services"]
|
||||
).get_services(),
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rules")
|
||||
def rules_page():
|
||||
zones = list(_safely(get_active_zones, {}).keys())
|
||||
return render_template("rules.html", zones=zones)
|
||||
|
||||
|
||||
@app.route("/nat")
|
||||
def nat_page():
|
||||
zones = {}
|
||||
for name in _safely(get_active_zones, {}):
|
||||
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
|
||||
return render_template("nat.html", zones=zones)
|
||||
|
||||
|
||||
@app.route("/dhcp")
|
||||
def dhcp_page():
|
||||
return render_template(
|
||||
"dhcp.html",
|
||||
config=_safely(dnsmasq_config, {}),
|
||||
status=_safely(dnsmasq_status, {}),
|
||||
leases=_safely(get_lease_table, []),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/proxy")
|
||||
def proxy_page():
|
||||
return render_template(
|
||||
"proxy.html", domains=_safely(get_domains, []), config=_safely(nginx_config, {})
|
||||
)
|
||||
|
||||
|
||||
@app.route("/certs")
|
||||
def certs_page():
|
||||
return render_template(
|
||||
"certs.html", certs=_safely(list_certs, []), email=_safely(get_email, "")
|
||||
)
|
||||
|
||||
|
||||
@app.route("/wireguard")
|
||||
def wireguard_page():
|
||||
return render_template(
|
||||
"wireguard.html", config=_safely(wg_config, {}), status=_safely(wg_status, {})
|
||||
)
|
||||
|
||||
|
||||
@app.route("/logs")
|
||||
def logs_page():
|
||||
return render_template("logs.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=9090)
|
||||
@@ -0,0 +1,147 @@
|
||||
// Toast notification system
|
||||
function showToast(message, type = "info") {
|
||||
const container = document.querySelector(".toast") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast-message toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateX(40px)";
|
||||
toast.style.transition = "all 0.3s ease";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast";
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.add("show");
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.remove("show");
|
||||
}
|
||||
|
||||
// Confirm dialog
|
||||
function confirmAction(message, onConfirm) {
|
||||
const existing = document.getElementById("confirm-modal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "confirm-modal";
|
||||
modal.className = "modal";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<p class="mb-2">${message}</p>
|
||||
<div style="display:flex; gap:0.75rem; justify-content:flex-end;">
|
||||
<button class="btn btn-outline" id="confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
openModal("confirm-modal");
|
||||
document.getElementById("confirm-cancel").onclick = () => closeModal("confirm-modal");
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal("confirm-modal");
|
||||
});
|
||||
}
|
||||
|
||||
function setupConfirmCallback(callback) {
|
||||
document.getElementById("confirm-ok")?.addEventListener("click", () => {
|
||||
closeModal("confirm-modal");
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh with HTMX
|
||||
function startAutoRefresh(endpoint, target, interval) {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("hx-get", endpoint);
|
||||
el.setAttribute("hx-target", `#${target}`);
|
||||
el.setAttribute("hx-swap", "innerHTML");
|
||||
el.setAttribute("hx-trigger", `every ${interval}s`);
|
||||
el.setAttribute("hx-swap-oob", "true");
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
// Time formatting
|
||||
function formatTime(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
||||
}
|
||||
|
||||
// Bytes formatting
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// Form helpers
|
||||
function resetForm(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (form) form.reset();
|
||||
}
|
||||
|
||||
function fillForm(formId, data) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// HTMX event handlers
|
||||
document.body.addEventListener("htmx:afterSwap", (evt) => {
|
||||
const toastHeader = evt.detail.xhr?.getResponseHeader("X-Toast");
|
||||
if (toastHeader) {
|
||||
const parts = toastHeader.split(":");
|
||||
const msg = parts.slice(1).join(":").trim();
|
||||
showToast(msg, parts[0]?.trim() || "info");
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:responseError", (evt) => {
|
||||
const status = evt.detail.xhr?.status || 0;
|
||||
showToast(`Request failed (${status})`, "error");
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", (evt) => {
|
||||
const target = evt.target;
|
||||
const btn = target.closest(".btn");
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Loading...";
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterRequest", (evt) => {
|
||||
const target = evt.target;
|
||||
const btn = target.closest(".btn");
|
||||
if (btn && btn.dataset.originalText !== undefined) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.originalText;
|
||||
delete btn.dataset.originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #0f3460;
|
||||
--bg-input: #1a1a2e;
|
||||
--accent: #00b4d8;
|
||||
--accent-hover: #0096c7;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--danger: #e63946;
|
||||
--success: #2ecc71;
|
||||
--warning: #f39c12;
|
||||
--border: #2a2a4a;
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: #0f0f23;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: transform 0.3s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar .logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
padding: 1.5rem;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
padding: 1rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover {
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar nav a.active {
|
||||
background: rgba(0, 180, 216, 0.12);
|
||||
color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table tr:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: rgba(0, 180, 216, 0.06);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #27ae60;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 180, 216, 0.15);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(243, 156, 18, 0.15);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: rgba(230, 57, 70, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Toasts */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
animation: toastSlideIn 0.3s ease forwards;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.toast-message.toast-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-message.toast-error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-message.toast-warning {
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-message.toast-info {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.modal.show .modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 24px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch .slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
/* Text Colors */
|
||||
.text-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Flex Utilities */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Spacing */
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-2,
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Vacuum Wall{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #0f3460;
|
||||
--bg-card-hover: #0f3460d0;
|
||||
--accent: #00b4d8;
|
||||
--accent-hover: #0096c7;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--danger: #e63946;
|
||||
--danger-hover: #c62828;
|
||||
--success: #2ecc71;
|
||||
--warning: #f1c40f;
|
||||
--border: #1a1a3e;
|
||||
--input-bg: #0d1b2a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-header span {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: all 0.15s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover {
|
||||
color: var(--text);
|
||||
background: rgba(0, 180, 216, 0.05);
|
||||
}
|
||||
|
||||
.sidebar nav a.active {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
min-height: 100vh;
|
||||
width: calc(100vw - 220px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-card .meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgba(0, 180, 216, 0.1);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(0, 180, 216, 0.03);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success { background: rgba(46, 204, 113, 0.15); color: var(--success); }
|
||||
.badge-warning { background: rgba(241, 196, 15, 0.15); color: var(--warning); }
|
||||
.badge-danger { background: rgba(230, 57, 70, 0.15); color: var(--danger); }
|
||||
.badge-info { background: rgba(0, 180, 216, 0.15); color: var(--accent); }
|
||||
|
||||
/* Status indicator */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-up { background: var(--success); }
|
||||
.status-down { background: var(--danger); }
|
||||
.status-pending { background: var(--warning); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success { background: #0d3b2e; border: 1px solid var(--success); color: var(--success); }
|
||||
.toast-error { background: #3b0d0d; border: 1px solid var(--danger); color: var(--danger); }
|
||||
.toast-warning { background: #3b3408; border: 1px solid var(--warning); color: var(--warning); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text); }
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Scrollable log */
|
||||
.log-viewer {
|
||||
background: #0a0a14;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--border);
|
||||
border-radius: 22px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.switch .slider:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--text);
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Flex utils */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-right { text-align: right; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
/* Service status list */
|
||||
.service-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.service-list li {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.service-list .svc-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Inline form row */
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inline-form .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
margin-left: 0;
|
||||
width: 100vw;
|
||||
padding: 16px;
|
||||
}
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inline-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
VACUUM WALL
|
||||
<span>Firewall Management</span>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/dashboard" class="{{ 'active' if request.path == '/dashboard' or request.path == '/' else '' }}">Dashboard</a>
|
||||
<a href="/interfaces" class="{{ 'active' if request.path == '/interfaces' else '' }}">Interfaces</a>
|
||||
<a href="/zones" class="{{ 'active' if request.path == '/zones' else '' }}">Zones</a>
|
||||
<a href="/rules" class="{{ 'active' if request.path == '/rules' else '' }}">Rules</a>
|
||||
<a href="/nat" class="{{ 'active' if request.path == '/nat' else '' }}">NAT</a>
|
||||
<a href="/dhcp" class="{{ 'active' if request.path == '/dhcp' else '' }}">DHCP & DNS</a>
|
||||
<a href="/proxy" class="{{ 'active' if request.path == '/proxy' else '' }}">Proxy</a>
|
||||
<a href="/certs" class="{{ 'active' if request.path == '/certs' else '' }}">Certificates</a>
|
||||
<a href="/wireguard" class="{{ 'active' if request.path == '/wireguard' else '' }}">WireGuard</a>
|
||||
<a href="/logs" class="{{ 'active' if request.path == '/logs' else '' }}">Logs</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
function showToast(message, type, duration) {
|
||||
duration = duration || 4000;
|
||||
var container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(function() { toast.classList.add('show'); });
|
||||
setTimeout(function() {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(function() { toast.remove(); }, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
var clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||
if (clickedTab) clickedTab.classList.add('active');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.htmx-on-success').forEach(function(el) {
|
||||
var msg = el.getAttribute('data-success') || 'Operation successful';
|
||||
var type = el.getAttribute('data-type') || 'success';
|
||||
el.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail && evt.detail.successful) {
|
||||
showToast(msg, type);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-overlay.active').forEach(function(el) { el.classList.remove('active'); });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Certificates - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Certificates</h1>
|
||||
<div class="subtitle">SSL/TLS certificate management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('issue-cert-modal')">+ Issue New Certificate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Issuer</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Days Left</th>
|
||||
<th style="width:120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cert in (certs or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
|
||||
<td class="text-sm">{{ cert.get('issuer', '-') }}</td>
|
||||
<td>{{ cert.get('expiry', 'N/A') }}</td>
|
||||
<td>
|
||||
{% set days = cert.get('days_remaining') %}
|
||||
{% if cert.get('expired') or (days is not none and days <= 0) %}
|
||||
<span class="badge badge-danger">Expired{% if days %} ({{ days }}d ago){% endif %}</span>
|
||||
{% elif days is not none and days <= 30 %}
|
||||
<span class="badge badge-warning">{{ days }} days</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">{{ days }} days</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/api/certs/renew/{{ cert.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Renewal started for {{ cert.domain }}" onsuccess="setTimeout(function(){ location.reload(); }, 2000);">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (certs or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Issue Certificate Modal -->
|
||||
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
|
||||
<div class="modal">
|
||||
<h2>Issue New Certificate</h2>
|
||||
<form hx-post="/api/certs/issue" hx-swap="none" class="htmx-on-success" data-success="Certificate issuance started" onsuccess="setTimeout(function(){closeModal('issue-cert-modal'); location.reload();}, 500);">
|
||||
<div class="form-group">
|
||||
<label for="cert-domain">Domain</label>
|
||||
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cert-email">Contact Email</label>
|
||||
<input type="email" id="cert-email" name="email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('issue-cert-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Issue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="subtitle">System overview and status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Zones</div>
|
||||
<div class="value">{{ zones|default([])|length }}</div>
|
||||
<div class="meta">Firewalld zones configured</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Proxy Domains</div>
|
||||
<div class="value">{{ domains|default([])|length }}</div>
|
||||
<div class="meta">SSL-terminated backends</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Certificates</div>
|
||||
<div class="value">{{ certs|default([])|length }}</div>
|
||||
{% set expired = certs|selectattr('expired')|list|default([])|length %}
|
||||
{% set expiring = certs|selectattr('days_remaining','le',30)|rejectattr('expired')|list|default([])|length %}
|
||||
<div class="meta">
|
||||
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
|
||||
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
|
||||
{% if expired == 0 and expiring == 0 %}All valid{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">WireGuard</div>
|
||||
<div class="value" style="font-size:20px;">
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}
|
||||
</div>
|
||||
<div class="meta">Tunnel state</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Active Leases</div>
|
||||
<div class="value">{{ leases|default([])|length }}</div>
|
||||
<div class="meta">DHCP clients connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Services</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for svc_name, svc in (services or {}).items() %}
|
||||
<div class="stat-card">
|
||||
<div class="label">{{ svc_name }}</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot {{ 'status-up' if svc.get('running') else 'status-down' }}"></span>
|
||||
{{ 'Running' if svc.get('running') else 'Stopped' }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
{% if svc.get('pid') %}PID {{ svc.pid }}{% endif %}
|
||||
{% if svc.get('since') %} · {{ svc.since }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not (services or {}) %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Firewalld</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Dnsmasq</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Nginx</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">wg0</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
{{ 'Up' if (wg_status is defined and wg_status.get('state') == 'up') else 'Down' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set warnings = [] %}
|
||||
{% if certs is defined %}
|
||||
{% for cert in certs %}
|
||||
{% if cert.get('expired') %}
|
||||
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %}
|
||||
{% elif cert.get('days_remaining') is not none and cert.days_remaining <= 30 %}
|
||||
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_remaining|string + " days") %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if warnings|length > 0 or (services is defined) %}
|
||||
<div class="section-title">Warnings & Activity</div>
|
||||
|
||||
<div class="card">
|
||||
{% if warnings|length > 0 %}
|
||||
<ul class="service-list">
|
||||
{% for w in warnings %}
|
||||
<li>
|
||||
<span class="status-dot status-pending"></span>
|
||||
<span class="svc-name">{{ w }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if not warnings and not (services or {}) %}
|
||||
<div class="text-muted text-sm">No warnings</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>DHCP & DNS</h1>
|
||||
<div class="subtitle">Dnsmasq configuration and lease management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DHCP Ranges -->
|
||||
<div class="section-title">DHCP Ranges</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/ranges" hx-swap="none" class="htmx-on-success" data-success="DHCP range added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="range-interface">Interface</label>
|
||||
<select id="range-interface" name="interface">
|
||||
<option value="">— Global —</option>
|
||||
{% for iface in (interfaces or []) %}
|
||||
<option value="{{ iface.get('name', '') }}">{{ iface.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-start">Start IP</label>
|
||||
<input type="text" id="range-start" name="start" placeholder="192.168.1.100" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-end">End IP</label>
|
||||
<input type="text" id="range-end" name="end" placeholder="192.168.1.200" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-lease">Lease Time</label>
|
||||
<input type="text" id="range-lease" name="lease_time" placeholder="1h" value="{{ (config or {}).get('dhcp_lease_time', '1h') }}" style="width:80px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Range</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Interface</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Lease Time</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rng.get('interface', '(global)') }}</td>
|
||||
<td>{{ rng.get('start', '') }}</td>
|
||||
<td>{{ rng.get('end', '') }}</td>
|
||||
<td>{{ rng.get('lease_time', '1h') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/ranges/{{ rng.get('start', '') }}/{{ rng.get('end', '') }}" hx-swap="none" class="htmx-on-success" data-success="Range removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this range?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Static Leases -->
|
||||
<div class="section-title">Static Leases</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/leases/static" hx-swap="none" class="htmx-on-success" data-success="Static lease added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="lease-mac">MAC Address</label>
|
||||
<input type="text" id="lease-mac" name="mac" placeholder="aa:bb:cc:dd:ee:ff" required style="width:180px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lease-ip">IP Address</label>
|
||||
<input type="text" id="lease-ip" name="ip" placeholder="192.168.1.50" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lease-host">Hostname</label>
|
||||
<input type="text" id="lease-host" name="hostname" placeholder="myhost" style="width:140px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Lease</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lease in ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('mac', '') }}</td>
|
||||
<td>{{ lease.get('ip', '') }}</td>
|
||||
<td>{{ lease.get('hostname', '-') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this lease?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-sm">No static leases configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom DNS Records -->
|
||||
<div class="section-title">Custom DNS Records</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/dns/records" hx-swap="none" class="htmx-on-success" data-success="DNS record added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="dns-ip">IP Address</label>
|
||||
<input type="text" id="dns-ip" name="ip" placeholder="192.168.1.10" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dns-hostname">Hostname / Domain</label>
|
||||
<input type="text" id="dns-hostname" name="hostname" placeholder="host.local" required style="width:200px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Record</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rec in ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rec.get('ip', '') }}</td>
|
||||
<td>{{ rec.get('hostname', '') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/dns/records/{{ rec.get('ip', '') }}/{{ rec.get('hostname', '') }}" hx-swap="none" class="htmx-on-success" data-success="Record removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this record?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted text-sm">No custom DNS records</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current DHCP Leases -->
|
||||
<div class="section-title">Current DHCP Leases</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Expires</th>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th>Client ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lease in (leases or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('expires', 'N/A') }}</td>
|
||||
<td>{{ lease.get('mac', 'N/A') }}</td>
|
||||
<td>{{ lease.get('ip', 'N/A') }}</td>
|
||||
<td>{{ lease.get('hostname', '*') or '*' }}</td>
|
||||
<td class="text-muted text-sm">{{ lease.get('client_id', 'N/A') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (leases or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No active DHCP leases</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-2 text-right">
|
||||
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/reload" hx-swap="none" class="htmx-on-success" data-success="Dnsmasq configuration reloaded">Apply & Restart Dnsmasq</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interfaces - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Interfaces</h1>
|
||||
<div class="subtitle">Network interface to zone bindings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Interface</th>
|
||||
<th>MAC Address</th>
|
||||
<th>IP Address</th>
|
||||
<th>State</th>
|
||||
<th>Zone</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for iface in (interfaces or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
||||
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
|
||||
<td>
|
||||
{% for ip in iface.get('ips', []) %}
|
||||
{{ ip }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% if not iface.get('ips') %}N/A{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'status-up' if iface.get('state') == 'up' else 'status-down' }}"></span>
|
||||
{{ 'Up' if iface.get('state') == 'up' else 'Down' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if zones %}
|
||||
<select
|
||||
class="htmx-on-success"
|
||||
data-success="Zone updated for {{ iface.name }}"
|
||||
hx-post="/api/firewall/zones/__ZONE__/interfaces/{{ iface.name }}"
|
||||
hx-swap="none"
|
||||
hx-select-oob="#toast-container *"
|
||||
onchange="assignInterfaceToZone(this, '{{ iface.name }}', '{{ iface.get('zone', '') }}')"
|
||||
>
|
||||
{% for zone in zones %}
|
||||
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<span class="text-muted">No zones configured</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (interfaces or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function assignInterfaceToZone(selectEl, ifaceName, currentZone) {
|
||||
var zoneName = selectEl.value;
|
||||
var url = '/api/firewall/zones/' + encodeURIComponent(zoneName) + '/interfaces/' + encodeURIComponent(ifaceName);
|
||||
fetch(url, { method: 'POST' })
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
showToast('Assigned ' + ifaceName + ' to zone ' + zoneName, 'success');
|
||||
} else {
|
||||
return res.text().then(function(txt) { throw new Error(txt); });
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
showToast('Failed to assign: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>System Logs</h1>
|
||||
<div class="subtitle">Service logs and journal output</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted">Auto-refresh</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="htmx-indicator text-sm" style="color:var(--accent);">
|
||||
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="journal" onclick="switchTab('journal')">Journal</button>
|
||||
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
|
||||
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
|
||||
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-journal" class="tab-content active">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-journal"
|
||||
hx-get="/api/logs/journal"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading journal entries...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nginx-access" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-access"
|
||||
hx-get="/api/logs/nginx/access"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading Nginx access log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nginx-error" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-error"
|
||||
hx-get="/api/logs/nginx/error"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading Nginx error log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-dnsmasq" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-dnsmasq"
|
||||
hx-get="/api/logs/dnsmasq"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading dnsmasq log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var autoRefreshTimer = null;
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
var toggle = document.getElementById('auto-refresh-toggle');
|
||||
var indicators = document.querySelectorAll('.log-viewer');
|
||||
|
||||
if (toggle.checked) {
|
||||
indicators.forEach(function(el) {
|
||||
el.setAttribute('hx-trigger', 'every 15s');
|
||||
hx.trigger(el, 'htmx:refresh');
|
||||
});
|
||||
} else {
|
||||
indicators.forEach(function(el) {
|
||||
el.setAttribute('hx-trigger', 'never');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) {
|
||||
document.getElementById('refresh-indicator').style.display = 'inline';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
document.getElementById('refresh-indicator').style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}NAT - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>NAT & Port Forwarding</h1>
|
||||
<div class="subtitle">Masquerading and destination NAT rules</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Masquerade (Source NAT)</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th style="width:120px;">Masquerade</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for zone in (zones or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
|
||||
<td>
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
{% if zone.get('masquerade') %}checked{% endif %}
|
||||
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted text-sm">No zones configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Port Forwarding (DNAT)</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Forward Rule</h3>
|
||||
<form hx-post="/api/firewall/nat/forward" hx-swap="none" class="htmx-on-success" data-success="Forward rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="fw-zone">Zone</label>
|
||||
<select id="fw-zone" name="zone" required>
|
||||
<option value="">— Select —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-protocol">Protocol</label>
|
||||
<select id="fw-protocol" name="protocol">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-port">Port</label>
|
||||
<input type="number" id="fw-port" name="port" placeholder="80" min="1" max="65535" required style="width:80px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-target">Target Address</label>
|
||||
<input type="text" id="fw-target" name="target" placeholder="192.168.1.100" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-target-port">Target Port</label>
|
||||
<input type="number" id="fw-target-port" name="target_port" placeholder="80" min="1" max="65535" style="width:90px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th>Proto</th>
|
||||
<th>Port</th>
|
||||
<th>Target</th>
|
||||
<th>Tgt Port</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set all_forwards = [] %}
|
||||
{% for zone in (zones or []) %}
|
||||
{% for fwd in zone.get('forward_ports', []) %}
|
||||
{% set _ = all_forwards.append({'zone': zone.get('name'), 'proxy-protocol': fwd.get('proxy-protocol'), 'port': fwd.get('port'), 'to-addr': fwd.get('to-addr'), 'to-port': fwd.get('to-port')}) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for fwd in all_forwards %}
|
||||
<tr>
|
||||
<td><strong>{{ fwd.zone }}</strong></td>
|
||||
<td><span class="badge badge-info">{{ fwd['proxy-protocol'] }}</span></td>
|
||||
<td>{{ fwd.port }}</td>
|
||||
<td>{{ fwd['to-addr'] }}</td>
|
||||
<td>{{ fwd['to-port'] }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/firewall/nat/forward/{{ fwd.zone | urlencode }}/{{ fwd['proxy-protocol'] }}/{{ fwd['to-addr'] }}/{{ fwd['to-port'] }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this forward rule?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not all_forwards %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleMasquerade(zoneName, enabled) {
|
||||
var url = '/api/firewall/nat/masquerade/' + encodeURIComponent(zoneName);
|
||||
var body = JSON.stringify({ enable: enabled });
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
showToast('Masquerade ' + (enabled ? 'enabled' : 'disabled') + ' for ' + zoneName, 'success');
|
||||
} else {
|
||||
return res.text().then(function(t) { throw new Error(t); });
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
showToast('Failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Proxy - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>SSL Proxy Domains</h1>
|
||||
<div class="subtitle">Reverse proxy and SSL termination managed by Nginx</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
|
||||
<button class="btn btn-outline" hx-post="/api/proxy/reload" hx-swap="none" class="htmx-on-success" data-success="Nginx reloaded">Apply Changes (Reload Nginx)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Backend Host</th>
|
||||
<th>Backend Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Certificate</th>
|
||||
<th style="width:140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for domain in (domains or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
|
||||
<td>{{ domain.get('backend_host', '-') }}</td>
|
||||
<td>{{ domain.get('backend_port', '-') }}</td>
|
||||
<td><span class="badge badge-info">{{ domain.get('protocol', 'http') }}</span></td>
|
||||
<td>
|
||||
{% set matched_cert = None %}
|
||||
{% if certs %}
|
||||
{% for cert in certs %}
|
||||
{% if cert.get('domain') == domain.get('domain') %}
|
||||
{% set matched_cert = cert %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if matched_cert %}
|
||||
{% if matched_cert.get('expired') %}
|
||||
<span class="badge badge-danger">Expired</span>
|
||||
{% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %}
|
||||
<span class="badge badge-warning">{{ matched_cert.days_remaining }}d</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">Valid</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-danger">No cert</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
|
||||
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (domains or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Modal -->
|
||||
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Add Proxy Domain</h2>
|
||||
<form hx-post="/api/proxy/domains" hx-swap="none" class="htmx-on-success" data-success="Domain added" onsuccess="setTimeout(function(){closeModal('add-domain-modal'); location.reload();}, 300);">
|
||||
<div class="form-group">
|
||||
<label for="new-domain">Domain</label>
|
||||
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-backend-host">Backend Host</label>
|
||||
<input type="text" id="new-backend-host" name="backend_host" placeholder="127.0.0.1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-backend-port">Backend Port</label>
|
||||
<input type="number" id="new-backend-port" name="backend_port" placeholder="8080" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-protocol">Backend Protocol</label>
|
||||
<select id="new-protocol" name="protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-domain-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Domain</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Domain Modal -->
|
||||
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Edit Proxy Domain</h2>
|
||||
<form id="edit-domain-form" hx-swap="none" class="htmx-on-success" data-success="Domain updated" onsuccess="setTimeout(function(){closeModal('edit-domain-modal'); location.reload();}, 300);">
|
||||
<input type="hidden" id="edit-original-domain" name="original_domain">
|
||||
<div class="form-group">
|
||||
<label for="edit-domain">Domain</label>
|
||||
<input type="text" id="edit-domain" name="domain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-backend-host">Backend Host</label>
|
||||
<input type="text" id="edit-backend-host" name="backend_host" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-backend-port">Backend Port</label>
|
||||
<input type="number" id="edit-backend-port" name="backend_port" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-protocol">Backend Protocol</label>
|
||||
<select id="edit-protocol" name="protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('edit-domain-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openEditDomainModal(domainName, d) {
|
||||
document.getElementById('edit-original-domain').value = d.domain;
|
||||
document.getElementById('edit-domain').value = d.domain;
|
||||
document.getElementById('edit-backend-host').value = d.backend_host || '';
|
||||
document.getElementById('edit-backend-port').value = d.backend_port || '';
|
||||
document.getElementById('edit-protocol').value = d.protocol || 'http';
|
||||
var form = document.getElementById('edit-domain-form');
|
||||
var target = '/api/proxy/domains/' + encodeURIComponent(d.domain);
|
||||
form.setAttribute('hx-put', target);
|
||||
openModal('edit-domain-modal');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rules - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Rich Rules</h1>
|
||||
<div class="subtitle">Firewalld rich firewall rules per zone</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Rule</h3>
|
||||
<form hx-post="/api/firewall/rules" hx-swap="none" class="htmx-on-success" data-success="Rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="rule-zone">Zone</label>
|
||||
<select id="rule-zone" name="zone" required>
|
||||
<option value="">— Select zone —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule-text">Rule Expression</label>
|
||||
<input type="text" id="rule-text" name="rule" placeholder="e.g., rule family=ipv4 source address=192.168.1.0/24 accept" required style="min-width:420px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-muted text-sm mt-2">
|
||||
Reference: <a href="https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" target="_blank" style="color:var(--accent);">firewalld rich language syntax</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rules or False %}
|
||||
{% for zone_name, zone_rules in rules.items() %}
|
||||
<div class="card">
|
||||
<h3>Zone: <span style="color:var(--accent);">{{ zone_name or '(default)' }}</span></h3>
|
||||
{% if zone_rules %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Rule</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in zone_rules %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ loop.index }}</td>
|
||||
<td style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/firewall/rules/{{ zone_name | urlencode }}/{{ loop.index0 }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this rule?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted text-sm">No rich rules configured for this zone.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not (zones or []) %}
|
||||
<div class="card" style="border-color:var(--warning);">
|
||||
<div class="text-muted text-sm" style="color:var(--warning);">No zones configured. Create a zone first before adding rich rules.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}WireGuard - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>WireGuard</h1>
|
||||
<div class="subtitle">VPN tunnel management</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/down"
|
||||
hx-swap="none"
|
||||
class="htmx-on-success"
|
||||
data-success="Tunnel stopped"
|
||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
||||
Stop Tunnel
|
||||
</button>
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/up"
|
||||
hx-swap="none"
|
||||
class="htmx-on-success"
|
||||
data-success="Tunnel started"
|
||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
||||
Start Tunnel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tunnel Status -->
|
||||
<div class="card mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3>
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
Tunnel State: <strong>{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}</strong>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-sm text-muted">
|
||||
{% if config %}
|
||||
Listen Port: <strong>{{ config.get('listen_port', 'N/A') }}</strong> |
|
||||
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> |
|
||||
Address: <strong>{{ config.get('address', 'N/A') }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Peer Form -->
|
||||
<div class="section-title">Peers</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Peer</h3>
|
||||
<form hx-post="/api/wireguard/peers" hx-swap="none" class="htmx-on-success" data-success="Peer added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="peer-name">Name</label>
|
||||
<input type="text" id="peer-name" name="name" placeholder="client-1" required style="width:140px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-pubkey">Public Key</label>
|
||||
<input type="text" id="peer-pubkey" name="public_key" placeholder="Base64 public key (48 chars)" required style="width:260px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-allowed">Allowed IPs</label>
|
||||
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers|length + 2) %}{{ next }}/32" required style="width:160px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Peer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Peers Table -->
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Allowed IPs</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Latest Handshake</th>
|
||||
<th>Transfer</th>
|
||||
<th style="width:160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for peer in (peers or []) %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="status-dot {{ 'status-up' if peer.get('latest_handshake') else 'status-down' }}"></span>
|
||||
<strong>{{ peer.get('name', 'unnamed') }}</strong>
|
||||
</td>
|
||||
<td style="font-family:monospace;font-size:11px;">{{ peer.get('public_key', 'N/A')[:20] }}...</td>
|
||||
<td class="text-sm">{{ peer.get('allowed_ips', '-') }}</td>
|
||||
<td class="text-sm">{{ peer.get('endpoint', '-') }}</td>
|
||||
<td class="text-sm">{{ peer.get('latest_handshake', 'Never') or 'Never' }}</td>
|
||||
<td class="text-sm">
|
||||
<div>Recv: {{ peer.get('transfer_recv', '0') or '0' }}</div>
|
||||
<div>Sent: {{ peer.get('transfer_sent', '0') or '0' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
|
||||
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (peers or []) %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadPeerConfig(peerName) {
|
||||
var url = '/api/wireguard/peers/' + encodeURIComponent(peerName) + '/config';
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zones - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Zones</h1>
|
||||
<div class="subtitle">Firewalld zone management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for zone in (zones or []) %}
|
||||
<div class="card" style="position:relative;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
<div>
|
||||
<h3 style="font-size:16px;color:var(--accent);">{{ zone.get('name', 'unnamed') }}</h3>
|
||||
<div class="text-muted text-sm" style="margin-bottom:10px;">
|
||||
{% if zone.get('target') %}Target: {{ zone.target }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-muted" style="margin-bottom:4px;">Interfaces</div>
|
||||
{% if zone.get('interfaces') %}
|
||||
{% for iface in zone.interfaces %}
|
||||
<span class="badge badge-info">{{ iface }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-muted" style="margin-bottom:4px;">Services</div>
|
||||
{% if zone.get('services') %}
|
||||
{% for svc in zone.services %}
|
||||
<span class="badge badge-success">{{ svc }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
|
||||
<form method="POST" action="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-post="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-swap="none" onsubmit="return confirm('Delete zone {{ zone.name }}? This will affect traffic to its interfaces.');" class="htmx-on-success" data-success="Zone deleted">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<div class="card">
|
||||
<div class="text-muted text-sm">No zones configured. Create a zone to get started.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Create Zone Modal -->
|
||||
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
|
||||
<div class="modal">
|
||||
<h2>Create Zone</h2>
|
||||
<form hx-post="/api/firewall/zones" hx-swap="none" class="htmx-on-success" data-success="Zone created" onsuccess="setTimeout(function(){closeModal('create-zone-modal');},500); location.reload();">
|
||||
<div class="form-group">
|
||||
<label for="zone-name">Zone Name</label>
|
||||
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zone-target">Target</label>
|
||||
<select id="zone-target" name="target">
|
||||
<option value="default">default</option>
|
||||
<option value="%%REJECT%%">%REJECT%</option>
|
||||
<option value="%%DROP%%">%DROP%</option>
|
||||
<option value="%%ACCEPT%%">%ACCEPT%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zone-services">Default Services (comma-separated)</label>
|
||||
<input type="text" id="zone-services" name="services" placeholder="e.g., dhcp, dns, ssh">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('create-zone-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user