From 3193ed3a3b256b511f28ab32e91fc2c418959d55 Mon Sep 17 00:00:00 2001 From: Lucas Robles <117331433+MauRoblesss@users.noreply.github.com> Date: Sun, 23 Nov 2025 23:48:28 -0300 Subject: [PATCH] Enhance firewall sync and IP inventory --- .gitignore | 7 + app.py | 473 +++++++++++++++++++++++++++++++++++ readme.md | 58 ++++- requirements.txt | 2 + static/styles.css | 171 +++++++++++++ static/xerohost-logo.svg | 8 + templates/base.html | 51 ++++ templates/dashboard.html | 144 +++++++++++ templates/firewall.html | 84 +++++++ templates/nodes.html | 75 ++++++ templates/prefix_detail.html | 80 ++++++ templates/prefixes.html | 69 +++++ 12 files changed, 1221 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/styles.css create mode 100644 static/xerohost-logo.svg create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/firewall.html create mode 100644 templates/nodes.html create mode 100644 templates/prefix_detail.html create mode 100644 templates/prefixes.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51e34c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +instance/ +*.db +*.sqlite3 +.env +.venv diff --git a/app.py b/app.py new file mode 100644 index 0000000..22cab56 --- /dev/null +++ b/app.py @@ -0,0 +1,473 @@ +import os +import sqlite3 +from datetime import datetime +from ipaddress import ip_address, ip_network +from typing import Any, Dict, Iterable, List, Tuple + +import requests +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for + +DATABASE = "xero_net.db" +MAX_IP_EXPANSION = int(os.environ.get("MAX_IP_EXPANSION", "4096")) + + +def create_app() -> Flask: + app = Flask(__name__) + app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET", "change-me") + app.config["MAX_IP_EXPANSION"] = MAX_IP_EXPANSION + + def get_db_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DATABASE) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + def init_db() -> None: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS prefixes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + family TEXT NOT NULL, + cidr TEXT NOT NULL UNIQUE, + assigned_to TEXT, + notes TEXT, + created_at TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS firewall_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + target TEXT NOT NULL, + scope TEXT NOT NULL, + reason TEXT, + created_at TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + api_url TEXT NOT NULL, + api_token TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS ip_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prefix_id INTEGER NOT NULL REFERENCES prefixes(id) ON DELETE CASCADE, + address TEXT NOT NULL UNIQUE, + status TEXT DEFAULT 'available', + assigned_to TEXT, + notes TEXT, + created_at TEXT NOT NULL + ) + """ + ) + conn.commit() + conn.close() + + def sync_missing_addresses() -> None: + conn = get_db_connection() + prefixes = conn.execute( + """ + SELECT p.*, COUNT(ip.id) AS ip_count + FROM prefixes p + LEFT JOIN ip_addresses ip ON ip.prefix_id = p.id + GROUP BY p.id + """ + ).fetchall() + for prefix in prefixes: + if prefix["ip_count"] == 0: + network = ip_network(prefix["cidr"], strict=False) + if network.num_addresses <= MAX_IP_EXPANSION: + _bulk_insert_addresses(conn, prefix["id"], network) + conn.commit() + conn.close() + + def parse_prefix(cidr: str) -> Dict[str, str]: + network = ip_network(cidr, strict=False) + return {"family": f"IPv{network.version}", "cidr": str(network), "network": network} + + def validate_target(target: str) -> str: + try: + network = ip_network(target, strict=False) + return str(network) + except ValueError: + try: + return str(ip_address(target)) + except ValueError as exc: # pragma: no cover - guard clause + raise ValueError("El destino debe ser una IP o prefijo válido") from exc + + @app.before_request + def ensure_db() -> None: + init_db() + sync_missing_addresses() + + @app.route("/") + def dashboard(): + conn = get_db_connection() + prefixes = conn.execute("SELECT * FROM prefixes ORDER BY created_at DESC").fetchall() + firewall_rules = conn.execute( + "SELECT * FROM firewall_rules ORDER BY created_at DESC" + ).fetchall() + nodes = conn.execute("SELECT * FROM nodes ORDER BY created_at DESC").fetchall() + conn.close() + + assigned = [p for p in prefixes if p["assigned_to"]] + unassigned = [p for p in prefixes if not p["assigned_to"]] + family_counts = {"IPv4": 0, "IPv6": 0} + for prefix in prefixes: + family_counts[prefix["family"]] = family_counts.get(prefix["family"], 0) + 1 + + return render_template( + "dashboard.html", + prefixes=prefixes, + firewall_rules=firewall_rules, + nodes=nodes, + assigned_count=len(assigned), + unassigned_count=len(unassigned), + family_counts=family_counts, + ) + + @app.route("/prefixes", methods=["GET", "POST"]) + def manage_prefixes(): + conn = get_db_connection() + if request.method == "POST": + cidr = request.form.get("cidr", "").strip() + notes = request.form.get("notes", "").strip() + try: + parsed = parse_prefix(cidr) + except ValueError as exc: + flash(str(exc), "danger") + else: + network = parsed["network"] + if network.num_addresses > MAX_IP_EXPANSION: + flash( + f"El prefijo es demasiado grande para expandir ({network.num_addresses} IPs).", + "danger", + ) + conn.close() + return render_template( + "prefixes.html", + prefixes=conn.execute("SELECT * FROM prefixes ORDER BY created_at DESC").fetchall(), + ) + try: + conn.execute( + "INSERT INTO prefixes (family, cidr, notes, created_at) VALUES (?, ?, ?, ?)", + (parsed["family"], parsed["cidr"], notes, datetime.utcnow().isoformat()), + ) + prefix_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + _bulk_insert_addresses(conn, prefix_id, parsed["network"]) + conn.commit() + flash( + f"Prefijo agregado con {parsed['network'].num_addresses} IPs expandida.", + "success", + ) + except sqlite3.IntegrityError: + flash("El prefijo ya existe.", "warning") + prefixes = conn.execute( + """ + SELECT p.*, COUNT(ip.id) AS ip_count + FROM prefixes p + LEFT JOIN ip_addresses ip ON ip.prefix_id = p.id + GROUP BY p.id + ORDER BY p.created_at DESC + """ + ).fetchall() + conn.close() + return render_template("prefixes.html", prefixes=prefixes) + + @app.get("/prefixes/") + def view_prefix(prefix_id: int): + query = request.args.get("q", "").strip() + conn = get_db_connection() + prefix = conn.execute("SELECT * FROM prefixes WHERE id = ?", (prefix_id,)).fetchone() + if not prefix: + conn.close() + flash("Prefijo no encontrado.", "danger") + return redirect(url_for("manage_prefixes")) + addresses_query = "SELECT * FROM ip_addresses WHERE prefix_id = ?" + params: Tuple[Any, ...] = (prefix_id,) + if query: + addresses_query += " AND address LIKE ?" + params += (f"%{query}%",) + addresses_query += " ORDER BY address LIMIT 500" + addresses = conn.execute(addresses_query, params).fetchall() + total_ips = conn.execute( + "SELECT COUNT(*) FROM ip_addresses WHERE prefix_id = ?", (prefix_id,) + ).fetchone()[0] + conn.close() + return render_template( + "prefix_detail.html", + prefix=prefix, + addresses=addresses, + total_ips=total_ips, + query=query, + ) + + @app.post("/ip-addresses//assign") + def assign_address(address_id: int): + assigned_to = request.form.get("assigned_to", "").strip() or None + status = request.form.get("status", "assigned").strip() or "assigned" + notes = request.form.get("notes", "").strip() or None + conn = get_db_connection() + address = conn.execute( + "SELECT * FROM ip_addresses WHERE id = ?", (address_id,) + ).fetchone() + if not address: + conn.close() + flash("IP no encontrada.", "danger") + return redirect(url_for("manage_prefixes")) + conn.execute( + "UPDATE ip_addresses SET assigned_to = ?, status = ?, notes = ? WHERE id = ?", + (assigned_to, status, notes, address_id), + ) + conn.commit() + conn.close() + flash(f"IP {address['address']} actualizada.", "success") + return redirect(url_for("view_prefix", prefix_id=address["prefix_id"])) + + @app.post("/prefixes//assign") + def assign_prefix(prefix_id: int): + assigned_to = request.form.get("assigned_to", "").strip() + notes = request.form.get("notes", "").strip() + conn = get_db_connection() + conn.execute( + "UPDATE prefixes SET assigned_to = ?, notes = ? WHERE id = ?", + (assigned_to or None, notes, prefix_id), + ) + conn.commit() + conn.close() + flash("Prefijo asignado/actualizado.", "success") + return redirect(url_for("manage_prefixes")) + + @app.post("/prefixes//release") + def release_prefix(prefix_id: int): + conn = get_db_connection() + conn.execute( + "UPDATE prefixes SET assigned_to = NULL WHERE id = ?", + (prefix_id,), + ) + conn.commit() + conn.close() + flash("Prefijo liberado.", "info") + return redirect(url_for("manage_prefixes")) + + def _bulk_insert_addresses(conn: sqlite3.Connection, prefix_id: int, network) -> None: + addresses: Iterable[str] + # ip_network returns an iterator over all addresses including network/broadcast + addresses = (str(addr) for addr in network) + rows: List[Tuple[int, str, str]] = [] + now = datetime.utcnow().isoformat() + for addr in addresses: + rows.append((prefix_id, addr, now)) + conn.executemany( + "INSERT OR IGNORE INTO ip_addresses (prefix_id, address, created_at) VALUES (?, ?, ?)", + rows, + ) + + @app.route("/firewall", methods=["GET", "POST"]) + def firewall(): + conn = get_db_connection() + if request.method == "POST": + target = request.form.get("target", "").strip() + scope = request.form.get("scope", "global").strip() or "global" + reason = request.form.get("reason", "").strip() + action = request.form.get("action", "block").strip() + try: + normalized_target = validate_target(target) + except ValueError as exc: + flash(str(exc), "danger") + else: + conn.execute( + "INSERT INTO firewall_rules (action, target, scope, reason, created_at) VALUES (?, ?, ?, ?, ?)", + ( + action, + normalized_target, + scope, + reason, + datetime.utcnow().isoformat(), + ), + ) + conn.commit() + flash("Regla de firewall creada.", "success") + rules = conn.execute("SELECT * FROM firewall_rules ORDER BY created_at DESC").fetchall() + conn.close() + return render_template("firewall.html", rules=rules) + + @app.route("/nodes", methods=["GET", "POST"]) + def manage_nodes(): + conn = get_db_connection() + if request.method == "POST": + name = request.form.get("name", "").strip() + api_url = request.form.get("api_url", "").strip() + api_token = request.form.get("api_token", "").strip() + if name and api_url and api_token: + conn.execute( + "INSERT INTO nodes (name, api_url, api_token, created_at) VALUES (?, ?, ?, ?)", + (name, api_url, api_token, datetime.utcnow().isoformat()), + ) + conn.commit() + flash("Nodo agregado.", "success") + else: + flash("Todos los campos de nodo son obligatorios.", "danger") + nodes = conn.execute("SELECT * FROM nodes ORDER BY created_at DESC").fetchall() + conn.close() + return render_template("nodes.html", nodes=nodes) + + @app.post("/firewall//push") + def push_rule(rule_id: int): + conn = get_db_connection() + rule = conn.execute( + "SELECT * FROM firewall_rules WHERE id = ?", (rule_id,) + ).fetchone() + nodes = conn.execute("SELECT * FROM nodes").fetchall() + conn.close() + if not rule: + flash("Regla no encontrada.", "danger") + elif not nodes: + flash("No hay nodos configurados para sincronizar.", "warning") + else: + successes, failures = dispatch_rule_to_nodes(rule, nodes) + flash( + f"Regla {rule['action']} {rule['target']} enviada a {successes} nodos, falló en {failures}.", + "info" if failures == 0 else "warning", + ) + return redirect(url_for("firewall")) + + def dispatch_rule_to_nodes(rule: sqlite3.Row, nodes: Iterable[sqlite3.Row]) -> Tuple[int, int]: + success = 0 + failure = 0 + payload = { + "action": rule["action"], + "target": rule["target"], + "scope": rule["scope"], + "reason": rule["reason"], + "created_at": rule["created_at"], + } + for node in nodes: + url = f"{node['api_url'].rstrip('/')}/api/firewall/rules" + headers = {"Authorization": f"Bearer {node['api_token']}"} + try: + response = requests.post(url, json=payload, headers=headers, timeout=5) + response.raise_for_status() + except requests.RequestException: + failure += 1 + continue + success += 1 + return success, failure + + # API endpoints + @app.get("/api/prefixes") + def api_prefixes(): + conn = get_db_connection() + prefixes = conn.execute("SELECT * FROM prefixes").fetchall() + conn.close() + return jsonify([dict(row) for row in prefixes]) + + @app.post("/api/prefixes") + def api_create_prefix(): + payload: Dict[str, Any] = request.get_json(force=True) + cidr = str(payload.get("cidr", "")).strip() + notes = str(payload.get("notes", "")).strip() + parsed = parse_prefix(cidr) + if parsed["network"].num_addresses > MAX_IP_EXPANSION: + return ( + jsonify( + { + "error": "Prefijo demasiado grande para expandir", + "limit": MAX_IP_EXPANSION, + "total": parsed["network"].num_addresses, + } + ), + 400, + ) + conn = get_db_connection() + try: + conn.execute( + "INSERT INTO prefixes (family, cidr, notes, created_at) VALUES (?, ?, ?, ?)", + (parsed["family"], parsed["cidr"], notes, datetime.utcnow().isoformat()), + ) + prefix_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + _bulk_insert_addresses(conn, prefix_id, parsed["network"]) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + return jsonify({"error": "El prefijo ya existe"}), 409 + conn.close() + return jsonify({"status": "ok", "prefix": {"cidr": parsed["cidr"]}, "ips": parsed["network"].num_addresses}), 201 + + @app.post("/api/firewall/block") + def api_block(): + payload = request.get_json(force=True) + target = str(payload.get("target", "")).strip() + scope = str(payload.get("scope", "global") or "global").strip() + reason = str(payload.get("reason", "")).strip() + normalized = validate_target(target) + conn = get_db_connection() + conn.execute( + "INSERT INTO firewall_rules (action, target, scope, reason, created_at) VALUES (?, ?, ?, ?, ?)", + ("block", normalized, scope, reason, datetime.utcnow().isoformat()), + ) + conn.commit() + conn.close() + return jsonify({"status": "blocked", "target": normalized, "scope": scope}) + + @app.post("/api/firewall/unblock") + def api_unblock(): + payload = request.get_json(force=True) + target = str(payload.get("target", "")).strip() + normalized = validate_target(target) + conn = get_db_connection() + conn.execute( + "INSERT INTO firewall_rules (action, target, scope, reason, created_at) VALUES (?, ?, 'global', ?)", + ("unblock", normalized, payload.get("reason", "")), + ) + conn.commit() + conn.close() + return jsonify({"status": "unblocked", "target": normalized}) + + @app.get("/api/prefixes//addresses") + def api_prefix_addresses(prefix_id: int): + conn = get_db_connection() + prefix = conn.execute("SELECT * FROM prefixes WHERE id = ?", (prefix_id,)).fetchone() + if not prefix: + conn.close() + return jsonify({"error": "Prefijo no encontrado"}), 404 + addresses = conn.execute( + "SELECT address, status, assigned_to, notes, created_at FROM ip_addresses WHERE prefix_id = ? ORDER BY address", + (prefix_id,), + ).fetchall() + conn.close() + return jsonify({"prefix": prefix["cidr"], "addresses": [dict(a) for a in addresses]}) + + @app.post("/api/firewall/push/") + def api_push_rule(rule_id: int): + conn = get_db_connection() + rule = conn.execute("SELECT * FROM firewall_rules WHERE id = ?", (rule_id,)).fetchone() + nodes = conn.execute("SELECT * FROM nodes").fetchall() + conn.close() + if not rule: + return jsonify({"error": "Regla no encontrada"}), 404 + if not nodes: + return jsonify({"error": "No hay nodos registrados"}), 400 + success, failure = dispatch_rule_to_nodes(rule, nodes) + return jsonify({"pushed": success, "failed": failure}) + + return app + + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0") diff --git a/readme.md b/readme.md index 9c558e3..a0d797e 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,57 @@ -. +# XeroHost Network Console + +Aplicación Flask para XeroHost que combina inventario IP y firewall distribuido con push automático a nodos Linux (iptables/nftables) vía API. + +## Requisitos +- Python 3.11+ +- pip + +## Instalación +```bash +pip install -r requirements.txt +python app.py +``` +La app se expone en `http://0.0.0.0:5000`. Usa `FLASK_SECRET` para la clave de sesión y `MAX_IP_EXPANSION` (por defecto 4096) para limitar la expansión de prefijos. + +## Funcionalidades clave +- **Inventario de prefijos**: alta de bloques IPv4/IPv6 con validación CIDR. Cada prefijo se expande en IPs individuales (incluyendo red/broadcast) y se pueden asignar/etiquetar desde la UI. +- **Detalle de IPs**: vista por prefijo con hasta 500 IPs por página y edición de estado (available/assigned/reserved). +- **Firewall global**: reglas de bloqueo/desbloqueo con motivo y ámbito, almacenadas en SQLite y sincronizadas a nodos remotos mediante HTTP con token Bearer. +- **Nodos**: registro de endpoints API de firewalls distribuidos para enviar las reglas. + +## API JSON +- `GET /api/prefixes` – lista de prefijos. +- `POST /api/prefixes` `{ "cidr": "192.0.2.0/24", "notes": "edge" }` – crea y expande IPs (hasta `MAX_IP_EXPANSION`). +- `GET /api/prefixes//addresses` – devuelve las IPs expandidas del prefijo. +- `POST /api/firewall/block` `{ "target": "203.0.113.4", "reason": "abuso", "scope": "global" }` +- `POST /api/firewall/unblock` `{ "target": "203.0.113.4" }` +- `POST /api/firewall/push/` – dispara el push de una regla a todos los nodos registrados. + +## Agente de nodo (iptables) +Ejemplo mínimo en Flask que ejecuta iptables y valida el token Bearer recibido desde este panel: +```python +from flask import Flask, request, jsonify +import os, subprocess +app = Flask(__name__) +TOKEN = os.environ["NODE_TOKEN"] + +@app.post("/api/firewall/rules") +def apply_rule(): + if request.headers.get("Authorization") != f"Bearer {TOKEN}": + return {"error": "unauthorized"}, 401 + data = request.get_json(force=True) + target = data.get("target") + action = data.get("action") + if action == "block": + cmd = ["iptables", "-I", "INPUT", "-s", target, "-j", "DROP"] + else: + cmd = ["iptables", "-D", "INPUT", "-s", target, "-j", "DROP"] + subprocess.run(cmd, check=False) + return {"status": "ok", "applied": cmd} + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) +``` +Registra el nodo en este panel con `API URL=http://:8080` y `Token=NODE_TOKEN` para que los pushes funcionen. + +> La base de datos es `xero_net.db` (SQLite). Adapta permisos/ruta según tu entorno. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f753748 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +requests==2.31.0 diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..bebb441 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,171 @@ +:root { + --xero-dark: #0b0f15; + --xero-panel: #111827; + --xero-panel-2: #0f172a; + --xero-accent: #ff1f2d; + --xero-accent-2: #f97316; + --xero-text: #e5e7eb; + --xero-muted: #94a3b8; +} + +body.bg-xero { + background: radial-gradient(circle at 20% 20%, rgba(255,31,45,0.08), transparent 25%), + radial-gradient(circle at 80% 10%, rgba(249,115,22,0.06), transparent 20%), + var(--xero-dark); + color: var(--xero-text); + min-height: 100vh; +} + +.glow { + position: fixed; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 10% 10%, rgba(255,31,45,0.08), transparent 30%), + radial-gradient(circle at 90% 20%, rgba(249,115,22,0.05), transparent 25%); + z-index: 0; +} + +.xero-navbar { + background: linear-gradient(90deg, #0c111a, #0f172a); + border-bottom: 1px solid rgba(255,255,255,0.05); + position: sticky; + top: 0; + z-index: 1000; +} + +.navbar-brand span { + color: #fff; + letter-spacing: 0.5px; +} + +.nav-link { + color: var(--xero-muted) !important; + transition: color 0.2s ease, transform 0.2s ease; +} + +.nav-link:hover, .nav-link:focus, .nav-link.active { + color: #fff !important; + transform: translateY(-1px); +} + +.card.xero-card { + background: linear-gradient(145deg, rgba(17,24,39,0.95), rgba(15,23,42,0.9)); + border: 1px solid rgba(255,255,255,0.04); + box-shadow: 0 15px 40px rgba(0,0,0,0.35); + color: var(--xero-text); +} + +.card.xero-card .card-title { + color: #fff; + letter-spacing: 0.4px; +} + +.card.xero-card .text-muted { + color: var(--xero-muted) !important; +} + +.section-title { + font-weight: 700; + letter-spacing: 0.6px; + color: #fff; +} + +.stat-value { + font-size: 2.4rem; + font-weight: 800; +} + +.badge-soft { + background: rgba(255,255,255,0.06); + color: var(--xero-text); + border: 1px solid rgba(255,255,255,0.05); +} + +.btn-xero { + background: linear-gradient(90deg, var(--xero-accent), var(--xero-accent-2)); + color: #fff; + border: none; + box-shadow: 0 10px 30px rgba(255,31,45,0.35); +} + +.btn-xero:hover { + color: #fff; + box-shadow: 0 15px 35px rgba(249,115,22,0.4); +} + +.table-dark thead th { + border-bottom: 1px solid rgba(255,255,255,0.08); +} + +.table-dark tbody tr { + border-color: rgba(255,255,255,0.04); +} + +.form-control, .form-select { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + color: var(--xero-text); +} + +.form-control:focus, .form-select:focus { + background: rgba(255,255,255,0.05); + border-color: var(--xero-accent); + box-shadow: 0 0 0 0.25rem rgba(255,31,45,0.15); + color: #fff; +} + +.input-group-text { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + color: var(--xero-muted); +} + +.toast .btn-close { + filter: invert(1); +} + +.table-hover tbody tr:hover { + background: rgba(255,255,255,0.03); +} + +.badge-status-available { + background: rgba(16,185,129,0.15); + color: #34d399; +} + +.badge-status-assigned { + background: rgba(59,130,246,0.15); + color: #93c5fd; +} + +.badge-status-reserved { + background: rgba(249,115,22,0.15); + color: #fcd34d; +} + +.small-label { + font-size: 0.8rem; + color: var(--xero-muted); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.hero { + background: linear-gradient(120deg, rgba(255,31,45,0.12), rgba(14,165,233,0.08)); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 20px; + padding: 32px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); +} + +.code-block { + background: rgba(0,0,0,0.45); + border-radius: 12px; + padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.9rem; +} + +.card-cta { + border-left: 3px solid var(--xero-accent); +} diff --git a/static/xerohost-logo.svg b/static/xerohost-logo.svg new file mode 100644 index 0000000..81b06f9 --- /dev/null +++ b/static/xerohost-logo.svg @@ -0,0 +1,8 @@ + + + + + + + XEROHOST + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5b13c7f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,51 @@ + + + + + + XeroHost Network + + + + + +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..453355f --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,144 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+
+
Panel XeroHost
+ Firewall + Inventario IP +
+

Controla tu red desde un solo lugar

+

Inventario completo de IPv4/IPv6, push de reglas globales a tus nodos Linux (iptables/nftables) y API lista para automatizar.

+ +
+
+
+
+
+
+
+
Reglas activas
+
{{ firewall_rules|length }}
+
+ +
+
+
+
+
+
+
+
Prefijos registrados
+
{{ prefixes|length }}
+
{{ family_counts['IPv4'] }} IPv4 · {{ family_counts['IPv6'] }} IPv6
+
+ +
+
+
+
+
+ +
+
+
+
+
Prefijos totales
+
{{ prefixes|length }}
+
Inventario global
+
+
+
+
+
+
+
Prefijos asignados
+
{{ assigned_count }}
+
Con dueño/documentado
+
+
+
+
+
+
+
Prefijos libres
+
{{ unassigned_count }}
+
Listos para asignar
+
+
+
+
+
+
+
Nodos conectados
+
{{ nodes|length }}
+
APIs de firewall activas
+
+
+
+
+ +
+
+
+
+
+
+
Actividad de firewall
+
Últimos 5 movimientos
+
+ Ver firewall +
+
    + {% for rule in firewall_rules[:5] %} +
  • +
    +
    {{ rule['action'] }} {{ rule['target'] }}
    +
    {{ rule['scope'] }} · {{ rule['reason'] or 'Sin motivo' }}
    +
    + {{ rule['created_at'] }} +
  • + {% else %} +
  • Sin reglas aún.
  • + {% endfor %} +
+
+
+
+
+
+
+
+
+
Nodos conectados
+
APIs donde se publican las reglas
+
+ Añadir nodo +
+ {% if nodes %} +
+ + + + {% for node in nodes %} + + + + + + + {% endfor %} + +
NombreAPITokenFecha
{{ node['name'] }}{{ node['api_url'] }}{{ node['api_token'] }}{{ node['created_at'] }}
+
+ {% else %} +

Aún no hay nodos registrados. Añade tus firewalls distribuidos.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/firewall.html b/templates/firewall.html new file mode 100644 index 0000000..bb1e98f --- /dev/null +++ b/templates/firewall.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
Firewall distribuido
+

Bloqueos globales y API

+
Empuja reglas a todos los nodos Linux registrados (iptables/nftables via API).
+
+ Volver +
+
+
+
+
+
+
Crear regla
+ Sync API +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
API
+
POST /api/firewall/block {"target":"1.1.1.1/32","reason":"abuso"}
+
+
+
+
+
+
+
+
+
Reglas activas
+ {{ rules|length }} en total +
+
+ + + + {% for rule in rules %} + + + + + + + + + {% else %} + + {% endfor %} + +
AcciónDestinoÁmbitoMotivoFechaPush
{{ rule['action'] }}{{ rule['target'] }}{{ rule['scope'] }}{{ rule['reason'] or '—' }}{{ rule['created_at'] }} +
+ +
+
No hay reglas.
+
+
+
+
+
+{% endblock %} diff --git a/templates/nodes.html b/templates/nodes.html new file mode 100644 index 0000000..7d151c9 --- /dev/null +++ b/templates/nodes.html @@ -0,0 +1,75 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
Nodos API
+

Firewalls distribuidos

+
Cada nodo recibe las reglas vía HTTP con token Bearer.
+
+ Volver +
+
+
+
+
+
+
Agregar nodo
+ REST +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
Ejemplo de agente
+
POST /api/firewall/rules
+Authorization: Bearer <token>
+{"action":"block","target":"1.1.1.1/32"}
+
+
+
+
+
+
+
+
+
Nodos configurados
+ {{ nodes|length }} activos +
+ {% if nodes %} +
+ + + + {% for node in nodes %} + + + + + + + {% endfor %} + +
NombreAPITokenFecha
{{ node['name'] }}{{ node['api_url'] }}{{ node['api_token'] }}{{ node['created_at'] }}
+
+ {% else %} +

No hay nodos todavía. Registra tus firewalls distribuidos para empujar reglas.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/prefix_detail.html b/templates/prefix_detail.html new file mode 100644 index 0000000..b8432a0 --- /dev/null +++ b/templates/prefix_detail.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
Detalle de prefijo
+

{{ prefix['cidr'] }}

+
{{ prefix['family'] }} · {{ total_ips }} IPs expandidas
+
+ Volver a prefijos +
+
+
+
+
+
Asignación del bloque
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
Direcciones IP
+
Primeros 500 registros para este bloque
+
+
+ + +
+
+
+ + + + {% for address in addresses %} + + + + + + + + {% else %} + + {% endfor %} + +
IPEstadoAsignado aNotas
{{ address['address'] }} + {{ address['status'] or 'available' }} + {{ address['assigned_to'] or 'Libre' }}{{ address['notes'] or '—' }} +
+ + + + +
+
No hay IPs para mostrar.
+
+
+
+
+
+{% endblock %} diff --git a/templates/prefixes.html b/templates/prefixes.html new file mode 100644 index 0000000..10f1318 --- /dev/null +++ b/templates/prefixes.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
Inventario IP
+

Prefijos y subnets

+
+ Volver al dashboard +
+
+
+
+
+
+
Agregar prefijo
+ Expansión automática +
+
+
+ + +
+
+ + +
+ +
+

Si el prefijo es mayor que {{ config['MAX_IP_EXPANSION'] or 4096 }} IPs, se bloqueará para evitar explosiones en base de datos.

+
+
+
+
+
+
+
+
Inventario
+ {{ prefixes|length }} prefijos +
+
+ + + + {% for prefix in prefixes %} + + + + + + + + + {% else %} + + {% endfor %} + +
FamiliaCIDRIPsAsignadoNotas
{{ prefix['family'] }}{{ prefix['cidr'] }}{{ prefix['ip_count'] }}{{ prefix['assigned_to'] or 'Sin asignar' }}{{ prefix['notes'] or '—' }} + Ver IPs +
+ +
+
Sin prefijos aún.
+
+
Cada prefijo se expande en IPs individuales (incluyendo red/broadcast) para asignación y documentación.
+
+
+
+
+{% endblock %}