This commit is contained in:
mjallen18
2026-04-07 20:36:32 -05:00
parent 928de1837b
commit 3234029ae5
10 changed files with 1039 additions and 2 deletions

View File

@@ -156,8 +156,52 @@ let
bouncerName = "nas-bouncer";
};
# secrets.apiKeyPath = config.sops.secrets."jallen-nas/crowdsec-firewall-bouncer-api-key".path;
settings = {
# The default api_url is derived from the LAPI's listen_uri, which is
# "0.0.0.0:8181" — a valid bind address but not a connectable URL.
# Override to the loopback address the bouncer should actually connect to.
api_url = "http://127.0.0.1:${toString cfg.port}";
};
};
};
# During activation (which runs as root), check whether the machine credential in
# client.yaml exists in the crowdsec SQLite DB. If not (e.g. after a DB wipe),
# clear client.yaml so the subsequent crowdsec-setup ExecStartPre re-registers.
# This runs before switch-to-configuration starts/restarts services, breaking the
# boot-time cycle where the ExecStartPre fix can't apply until the service succeeds.
system.activationScripts.crowdsec-check-machine-creds =
let
machineName = config.services.crowdsec.name;
in
{
text = ''
clientYaml="${cfg.configDir}/crowdsec/client.yaml"
dbPath="/var/lib/crowdsec/state/crowdsec.db"
if [ -f "$dbPath" ]; then
if [ -s "$clientYaml" ]; then
login=$(${pkgs.gnugrep}/bin/grep -oP '(?<=login: ).*' "$clientYaml" || true)
if [ -n "$login" ]; then
found=$(${pkgs.sqlite}/bin/sqlite3 "$dbPath" \
"SELECT COUNT(*) FROM machines WHERE machine_id='$login';" 2>/dev/null || echo "0")
if [ "$found" = "0" ]; then
echo "crowdsec activation: machine '$login' missing from DB resetting credentials"
${pkgs.coreutils}/bin/rm -f "$clientYaml"
# Also remove any stale entry under the configured machine name so
# 'cscli machines add ${machineName} --auto' doesn't fail with "user already exist"
${pkgs.sqlite}/bin/sqlite3 "$dbPath" \
"DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true
fi
fi
else
# client.yaml absent/empty ensure no stale name entry blocks re-registration
${pkgs.sqlite}/bin/sqlite3 "$dbPath" \
"DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true
fi
fi
'';
deps = [ ];
};
# The upstream crowdsec module uses ReadWritePaths (not StateDirectory) on
# crowdsec.service, meaning it expects /var/lib/crowdsec to exist as a real
@@ -181,7 +225,64 @@ let
services = {
crowdsec = {
serviceConfig = lib.mkMerge [
{ DynamicUser = lib.mkForce false; }
{
DynamicUser = lib.mkForce false;
# ProtectSystem=strict (set upstream) makes all paths read-only except
# those in ReadWritePaths. The credentials file lives on the NAS mount
# which is not listed by default, so cscli machines add fails with EROFS.
ReadWritePaths = [ "${cfg.configDir}/crowdsec" ];
# If the machine credentials in client.yaml don't match any machine in the
# SQLite DB (e.g. after a DB wipe while client.yaml persists), crowdsec
# fatals on startup. Detect this mismatch before crowdsec-setup runs:
# read the machine login from client.yaml, query the DB directly, and
# delete client.yaml if the machine is absent so the next crowdsec-setup
# invocation re-registers a fresh machine.
# Use mkBefore so this runs before the upstream crowdsec-setup ExecStartPre
# entries, giving crowdsec-setup a cleared client.yaml to re-register from.
ExecStartPre = lib.mkBefore [
(
"+"
+ (
let
machineName = config.services.crowdsec.name;
in
pkgs.writeShellScript "crowdsec-check-machine-creds" ''
set -euo pipefail
clientYaml="${cfg.configDir}/crowdsec/client.yaml"
dbPath="/var/lib/crowdsec/state/crowdsec.db"
sqlite="${pkgs.sqlite}/bin/sqlite3"
rm="${pkgs.coreutils}/bin/rm"
[ -f "$dbPath" ] || exit 0 # No DB yet; fresh install, nothing to fix
if [ -s "$clientYaml" ]; then
# Credentials file exists verify the login it contains is in the DB
login=$(${pkgs.gnugrep}/bin/grep -oP '(?<=login: ).*' "$clientYaml" || true)
if [ -n "$login" ]; then
found=$("$sqlite" "$dbPath" \
"SELECT COUNT(*) FROM machines WHERE machine_id='$login';" 2>/dev/null || echo "0")
if [ "$found" = "0" ]; then
echo "crowdsec: machine '$login' missing from DB resetting credentials"
"$rm" -f "$clientYaml"
"$sqlite" "$dbPath" \
"DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true
fi
fi
else
# Credentials file absent ensure no stale name row blocks machines add
stale=$("$sqlite" "$dbPath" \
"SELECT COUNT(*) FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || echo "0")
if [ "$stale" != "0" ]; then
echo "crowdsec: client.yaml absent but '${machineName}' in DB removing stale row"
"$sqlite" "$dbPath" \
"DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true
fi
fi
''
)
)
];
}
(lib.mkIf (cfg.ntfy.enable && cfg.ntfy.envFile != "") {
EnvironmentFile = [ cfg.ntfy.envFile ];
})

View File

@@ -890,7 +890,24 @@ let
restartUnits = [ "grafana.service" ];
};
systemd.services.grafana.serviceConfig.EnvironmentFile = config.sops.templates."grafana.env".path;
systemd.services.grafana.serviceConfig = {
EnvironmentFile = config.sops.templates."grafana.env".path;
# Grafana downloads plugins at runtime and occasionally creates subdirectories
# with overly restrictive permissions (e.g. 0700 for locales/*), which causes
# the next startup to fail with "permission denied" during plugin discovery.
# Fix any such directories before Grafana starts.
ExecStartPre = [
(
"+"
+ pkgs.writeShellScript "grafana-fix-plugin-perms" ''
pluginDir="${cfg.configDir}/grafana/plugins"
if [ -d "$pluginDir" ]; then
${pkgs.coreutils}/bin/chmod -R a+rX "$pluginDir"
fi
''
)
];
};
# The redis exporter needs AF_INET to reach TCP Redis instances.
# The default systemd hardening only allows AF_UNIX.

View File

@@ -0,0 +1,135 @@
{
config,
lib,
namespace,
pkgs,
...
}:
with lib;
let
name = "nebula-ui";
cfg = config.${namespace}.services.${name};
statsListenAddr = "${cfg.statsListenAddress}:${toString cfg.statsPort}";
nebulaUiConfig = lib.${namespace}.mkModule {
inherit config name;
description = "Nebula network web UI (stats + cert signing)";
options = {
# Override mkModule defaults: bind to localhost only; firewall closed by
# default since this service sits behind a Caddy reverse proxy.
listenAddress = lib.${namespace}.mkOpt types.str "127.0.0.1" "Address nebula-ui listens on";
openFirewall =
lib.${namespace}.mkBoolOpt false
"Open firewall for nebula-ui (not needed behind a reverse proxy)";
# ── Stats endpoint ───────────────────────────────────────────────────────
statsListenAddress =
lib.${namespace}.mkOpt types.str "127.0.0.1"
"Address nebula's stats HTTP endpoint listens on";
statsPort = lib.${namespace}.mkOpt types.port 8474 "Port nebula's stats HTTP endpoint listens on";
# ── CA secrets ───────────────────────────────────────────────────────────
# The CA cert/key are already decrypted by the nebula sops.nix.
# We need a *separate* sops secret for the CA key exposed to nebula-ui
# because the nebula module only exposes it to nebula-<network>.
caCertSecretKey =
lib.${namespace}.mkOpt types.str ""
"SOPS secret key for the CA certificate (e.g. \"pi5/nebula/ca-cert\")";
caKeySecretKey =
lib.${namespace}.mkOpt types.str ""
"SOPS secret key for the CA private key (e.g. \"pi5/nebula/ca-key\")";
secretsFile =
lib.${namespace}.mkOpt types.str ""
"Path to the SOPS secrets YAML that holds the CA cert + key";
# ── Network identity ─────────────────────────────────────────────────────
networkName =
lib.${namespace}.mkOpt types.str "jallen-nebula"
"Nebula network name (must match services.nebula.networkName)";
};
moduleConfig = {
assertions = [
{
assertion = cfg.caCertSecretKey != "";
message = "mjallen.services.nebula-ui.caCertSecretKey must be set";
}
{
assertion = cfg.caKeySecretKey != "";
message = "mjallen.services.nebula-ui.caKeySecretKey must be set";
}
{
assertion = cfg.secretsFile != "";
message = "mjallen.services.nebula-ui.secretsFile must be set";
}
];
# ── SOPS secrets owned by the nebula-ui service user ───────────────────
sops.secrets."${cfg.caCertSecretKey}" = {
sopsFile = cfg.secretsFile;
owner = name;
group = name;
restartUnits = [ "nebula-ui.service" ];
};
sops.secrets."${cfg.caKeySecretKey}" = {
sopsFile = cfg.secretsFile;
owner = name;
group = name;
restartUnits = [ "nebula-ui.service" ];
};
# ── User / group ────────────────────────────────────────────────────────
users.users.${name} = {
isSystemUser = true;
group = name;
description = "Nebula UI service user";
};
users.groups.${name} = { };
# ── Systemd service ─────────────────────────────────────────────────────
systemd.services.${name} = {
description = "Nebula network web UI";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"sops-nix.service"
];
environment = {
NEBULA_UI_CA_CERT_PATH = config.sops.secrets."${cfg.caCertSecretKey}".path;
NEBULA_UI_CA_KEY_PATH = config.sops.secrets."${cfg.caKeySecretKey}".path;
NEBULA_UI_STATS_URL = "http://${statsListenAddr}";
NEBULA_UI_NETWORK_NAME = cfg.networkName;
NEBULA_UI_LISTEN_HOST = cfg.listenAddress;
NEBULA_UI_LISTEN_PORT = toString cfg.port;
};
serviceConfig = {
ExecStart = "${pkgs.${namespace}.nebula-ui}/bin/nebula-ui";
User = name;
Group = name;
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadOnlyPaths = [
config.sops.secrets."${cfg.caCertSecretKey}".path
config.sops.secrets."${cfg.caKeySecretKey}".path
];
};
};
};
};
in
{
imports = [ nebulaUiConfig ];
}

View File

@@ -95,6 +95,17 @@ let
host = "any";
}
] "Nebula outbound firewall rules";
# -----------------------------------------------------------------------
# Stats / metrics HTTP endpoint
# -----------------------------------------------------------------------
stats = {
enable = lib.${namespace}.mkBoolOpt false "Enable the Nebula HTTP stats endpoint";
listenAddress = lib.${namespace}.mkOpt types.str "127.0.0.1" "Address the stats endpoint binds to";
statsPort = lib.${namespace}.mkOpt types.port 8474 "Port the stats endpoint listens on";
};
};
moduleConfig = {
environment.systemPackages = with pkgs; [ nebula ];
@@ -136,6 +147,12 @@ let
inbound = cfg.inboundRules;
outbound = cfg.outboundRules;
};
settings.stats = lib.mkIf cfg.stats.enable {
type = "json";
listen = "${cfg.stats.listenAddress}:${toString cfg.stats.statsPort}";
interval = "10s";
};
};
};
};

View File

@@ -149,6 +149,35 @@ let
nextcloud-setup = {
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
serviceConfig =
let
# Extract the override.config.php store-path from the already-evaluated
# tmpfiles rules list at Nix eval time, so we never have to parse files at
# runtime. The upstream module emits exactly one rule of the form:
# "L+ <dest> - - - - <storepath>"
overrideLine = lib.findFirst (
r: lib.hasInfix "override.config.php" r
) null config.systemd.tmpfiles.rules;
overrideStorePath =
if overrideLine != null then lib.last (lib.splitString " " overrideLine) else null;
in
lib.mkIf (overrideStorePath != null) {
# systemd-tmpfiles refuses to create the override.config.php symlink because
# /media/nas/main is owned by nix-apps (not root/nextcloud), triggering an
# "unsafe path transition" error. Work around this by creating the symlink
# directly as root (the '+' prefix) before the setup script's ownership check.
# The target store path is resolved at Nix eval time so it is always current.
ExecStartPre = [
(
"+"
+ pkgs.writeShellScript "nextcloud-fix-override-config" ''
dest="${cfg.dataDir}/nextcloud/config/override.config.php"
echo "Creating symlink: $dest -> ${overrideStorePath}"
${pkgs.coreutils}/bin/ln -sf "${overrideStorePath}" "$dest"
''
)
];
};
};
nextcloud-update-db = {
after = [ "postgresql.service" ];

View File

@@ -0,0 +1,252 @@
"""
nebula-ui — FastAPI web UI for managing a Nebula overlay network.
Reads configuration from environment variables (set by the NixOS module):
NEBULA_UI_CA_CERT_PATH — path to the decrypted CA cert file (from sops-nix)
NEBULA_UI_CA_KEY_PATH — path to the decrypted CA key file (from sops-nix)
NEBULA_UI_STATS_URL — nebula stats HTTP endpoint, e.g. http://127.0.0.1:8472
NEBULA_UI_NETWORK_NAME — nebula network name, e.g. "jallen-nebula"
NEBULA_UI_LISTEN_HOST — host to bind to (default: 127.0.0.1)
NEBULA_UI_LISTEN_PORT — port to listen on (default: 8472)
"""
from __future__ import annotations
import base64
import io
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Annotated
import httpx
import qrcode
import qrcode.image.svg
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
# ---------------------------------------------------------------------------
# Config from environment
# ---------------------------------------------------------------------------
CA_CERT_PATH = os.environ.get("NEBULA_UI_CA_CERT_PATH", "")
CA_KEY_PATH = os.environ.get("NEBULA_UI_CA_KEY_PATH", "")
STATS_URL = os.environ.get("NEBULA_UI_STATS_URL", "http://127.0.0.1:8472")
NETWORK_NAME = os.environ.get("NEBULA_UI_NETWORK_NAME", "nebula")
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(title="Nebula UI", docs_url=None, redoc_url=None)
_templates_dir = Path(__file__).parent / "templates"
# When installed via Nix the templates are copied into the same package dir
# as __init__.py (i.e. nebula_ui/templates/). __file__ resolves correctly.
templates = Jinja2Templates(directory=str(_templates_dir))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _qr_svg(data: str) -> str:
"""Return an inline SVG string for a QR code encoding *data*."""
factory = qrcode.image.svg.SvgPathImage
img = qrcode.make(
data, image_factory=factory, error_correction=qrcode.constants.ERROR_CORRECT_L
)
buf = io.BytesIO()
img.save(buf)
return buf.getvalue().decode()
def _nebula_cert_bin() -> str:
"""Return the path to nebula-cert (must be on PATH)."""
path = shutil.which("nebula-cert")
if path is None:
raise RuntimeError("nebula-cert not found on PATH")
return path
def _sign_cert(name: str, ip: str, groups: str, duration: str) -> tuple[str, str]:
"""
Sign a new Nebula host certificate using the CA files pointed to by
NEBULA_UI_CA_CERT_PATH / NEBULA_UI_CA_KEY_PATH.
Returns (cert_pem, key_pem) as strings.
Raises RuntimeError on failure.
"""
if not CA_CERT_PATH or not CA_KEY_PATH:
raise RuntimeError(
"CA cert/key paths are not configured "
"(NEBULA_UI_CA_CERT_PATH / NEBULA_UI_CA_KEY_PATH)"
)
ca_cert = Path(CA_CERT_PATH)
ca_key = Path(CA_KEY_PATH)
if not ca_cert.exists():
raise RuntimeError(f"CA cert not found: {ca_cert}")
if not ca_key.exists():
raise RuntimeError(f"CA key not found: {ca_key}")
with tempfile.TemporaryDirectory(prefix="nebula-ui-") as tmp:
tmp_path = Path(tmp)
out_crt = tmp_path / "host.crt"
out_key = tmp_path / "host.key"
cmd = [
_nebula_cert_bin(),
"sign",
"-name",
name,
"-ip",
ip,
"-ca-crt",
str(ca_cert),
"-ca-key",
str(ca_key),
"-out-crt",
str(out_crt),
"-out-key",
str(out_key),
]
if groups.strip():
cmd += ["-groups", groups.strip()]
if duration.strip():
cmd += ["-duration", duration.strip()]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"nebula-cert sign failed:\n{result.stderr}")
cert_pem = out_crt.read_text()
key_pem = out_key.read_text()
return cert_pem, key_pem
def _cert_info(cert_pem: str) -> dict:
"""Return parsed cert info by running nebula-cert print."""
with tempfile.NamedTemporaryFile(suffix=".crt", mode="w", delete=False) as f:
f.write(cert_pem)
f.flush()
result = subprocess.run(
[_nebula_cert_bin(), "print", "-json", "-path", f.name],
capture_output=True,
text=True,
)
Path(f.name).unlink(missing_ok=True)
if result.returncode != 0:
return {}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {}
def _fetch_stats() -> dict | None:
"""Fetch the nebula stats JSON. Returns None on error."""
try:
resp = httpx.get(f"{STATS_URL}/v1/stats", timeout=3.0)
resp.raise_for_status()
return resp.json()
except Exception:
pass
# Some nebula versions use /stats
try:
resp = httpx.get(f"{STATS_URL}/stats", timeout=3.0)
resp.raise_for_status()
return resp.json()
except Exception:
return None
def _fetch_hostmap() -> dict | None:
"""Fetch the nebula hostmap JSON."""
try:
resp = httpx.get(f"{STATS_URL}/v1/hostmap", timeout=3.0)
resp.raise_for_status()
return resp.json()
except Exception:
pass
try:
resp = httpx.get(f"{STATS_URL}/hostmap", timeout=3.0)
resp.raise_for_status()
return resp.json()
except Exception:
return None
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
stats = _fetch_stats()
hostmap = _fetch_hostmap()
return templates.TemplateResponse(
"index.html",
{
"request": request,
"network_name": NETWORK_NAME,
"stats": stats,
"hostmap": hostmap,
},
)
@app.get("/sign", response_class=HTMLResponse)
async def sign_form(request: Request):
return templates.TemplateResponse(
"sign.html",
{
"request": request,
"network_name": NETWORK_NAME,
"error": None,
"result": None,
},
)
@app.post("/sign", response_class=HTMLResponse)
async def sign_submit(
request: Request,
name: Annotated[str, Form()],
ip: Annotated[str, Form()],
groups: Annotated[str, Form()] = "",
duration: Annotated[str, Form()] = "",
):
error = None
result = None
try:
cert_pem, key_pem = _sign_cert(name, ip, groups, duration)
cert_info = _cert_info(cert_pem)
cert_qr = _qr_svg(cert_pem)
key_qr = _qr_svg(key_pem)
result = {
"name": name,
"ip": ip,
"cert_pem": cert_pem,
"key_pem": key_pem,
"cert_qr": cert_qr,
"key_qr": key_qr,
"cert_info": cert_info,
}
except Exception as exc:
error = str(exc)
return templates.TemplateResponse(
"sign.html",
{
"request": request,
"network_name": NETWORK_NAME,
"error": error,
"result": result,
},
)

View File

@@ -0,0 +1,52 @@
{
lib,
python3,
nebula,
runCommand,
writeShellApplication,
...
}:
let
pythonEnv = python3.withPackages (
ps: with ps; [
fastapi
uvicorn
httpx
qrcode
pillow # qrcode SVG path image factory dependency
jinja2
python-multipart # needed for FastAPI Form() parsing
]
);
# Install the app source as a proper Python package so uvicorn can import it.
appPkg = runCommand "nebula-ui-app" { } ''
pkgdir=$out/lib/python${python3.pythonVersion}/site-packages/nebula_ui
mkdir -p "$pkgdir"
cp ${./app.py} "$pkgdir/__init__.py"
cp -r ${./templates} "$pkgdir/templates"
'';
in
writeShellApplication {
name = "nebula-ui";
runtimeInputs = [
pythonEnv
nebula # provides nebula-cert on PATH
];
text = ''
export PYTHONPATH="${appPkg}/lib/python${python3.pythonVersion}/site-packages''${PYTHONPATH:+:$PYTHONPATH}"
exec uvicorn nebula_ui:app \
--host "''${NEBULA_UI_LISTEN_HOST:-127.0.0.1}" \
--port "''${NEBULA_UI_LISTEN_PORT:-8473}"
'';
meta = {
description = "Web UI for managing a Nebula overlay network stats, cert signing, QR codes";
mainProgram = "nebula-ui";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
};
}

View File

@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nebula UI — {{ network_name }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--accent: #6c8ef5;
--accent2: #4ade80;
--text: #e2e4ef;
--muted: #7a7f9a;
--danger: #f87171;
--radius: 8px;
--font: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 24px;
display: flex;
align-items: center;
gap: 32px;
height: 52px;
}
header .logo {
font-weight: 700;
font-size: 16px;
color: var(--accent);
letter-spacing: -.3px;
}
header nav a {
color: var(--muted);
text-decoration: none;
font-size: 13px;
padding: 6px 10px;
border-radius: var(--radius);
transition: color .15s, background .15s;
}
header nav a:hover,
header nav a.active {
color: var(--text);
background: var(--border);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 32px 24px;
}
h1 { font-size: 22px; font-weight: 600; margin-bottom: 24px; }
h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
margin-bottom: 20px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: .3px;
}
.badge-green { background: #14532d; color: var(--accent2); }
.badge-blue { background: #1e3a5f; color: var(--accent); }
.badge-red { background: #450a0a; color: var(--danger); }
.badge-gray { background: var(--border); color: var(--muted); }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 8px 12px;
color: var(--muted);
border-bottom: 1px solid var(--border);
font-weight: 500;
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,.02); }
.mono { font-family: ui-monospace, 'Cascadia Code', monospace; font-size: 12px; }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
}
.stat-box .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
.stat-box .value { font-size: 22px; font-weight: 700; color: var(--accent); }
.stat-box .unit { font-size: 11px; color: var(--muted); margin-left: 3px; }
form .field { margin-bottom: 16px; }
form label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
form input[type="text"] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 9px 12px;
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color .15s;
}
form input[type="text"]:focus { border-color: var(--accent); }
form input[type="text"]::placeholder { color: var(--muted); }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--radius);
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: opacity .15s;
}
.btn:hover { opacity: .85; }
.btn-primary { background: var(--accent); color: #fff; }
.alert {
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 20px;
font-size: 13px;
}
.alert-error { background: #450a0a; border: 1px solid #7f1d1d; color: var(--danger); }
.qr-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}
@media (max-width: 640px) { .qr-grid { grid-template-columns: 1fr; } }
.qr-box {
background: #fff;
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.qr-box .qr-label {
font-size: 12px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: .5px;
}
.qr-box svg { max-width: 100%; height: auto; }
details { margin-top: 16px; }
summary { cursor: pointer; color: var(--muted); font-size: 12px; }
pre {
margin-top: 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
font-family: ui-monospace, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
color: var(--text);
}
.no-data { color: var(--muted); font-size: 13px; padding: 20px 0; text-align: center; }
</style>
</head>
<body>
<header>
<span class="logo">nebula-ui</span>
<nav>
<a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>Stats</a>
<a href="/sign" {% if request.url.path == "/sign" %}class="active"{% endif %}>Sign Certificate</a>
</nav>
<span style="margin-left:auto; color:var(--muted); font-size:12px;">{{ network_name }}</span>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block content %}
<h1>Network Overview</h1>
{% if stats is none %}
<div class="card">
<div class="no-data">
Could not reach the Nebula stats endpoint at <code class="mono">{{ request.app.state.stats_url if request.app.state is defined else "configured URL" }}</code>.
Make sure <code class="mono">stats.enabled</code> and <code class="mono">stats.listen</code> are set in your Nebula config.
</div>
</div>
{% else %}
{# ── top-level counters ── #}
{% set meta = stats.get("meta", {}) %}
{% set network = stats.get("network", {}) %}
<div class="stat-grid">
<div class="stat-box">
<div class="label">Nebula Version</div>
<div class="value" style="font-size:15px; padding-top:4px;">{{ meta.get("version", "—") }}</div>
</div>
{% set peers = hostmap.get("Hosts", {}) if hostmap else {} %}
<div class="stat-box">
<div class="label">Active Peers</div>
<div class="value">{{ peers | length }}</div>
</div>
{% set counters = stats.get("counters", {}) %}
<div class="stat-box">
<div class="label">Tx Bytes</div>
<div class="value">{{ "{:,}".format(counters.get("send_bytes", 0)) }}</div>
</div>
<div class="stat-box">
<div class="label">Rx Bytes</div>
<div class="value">{{ "{:,}".format(counters.get("recv_bytes", 0)) }}</div>
</div>
<div class="stat-box">
<div class="label">Tx Packets</div>
<div class="value">{{ "{:,}".format(counters.get("send_packets", 0)) }}</div>
</div>
<div class="stat-box">
<div class="label">Rx Packets</div>
<div class="value">{{ "{:,}".format(counters.get("recv_packets", 0)) }}</div>
</div>
</div>
{# ── raw stats JSON ── #}
<details style="margin-bottom:20px;">
<summary>Raw stats JSON</summary>
<pre>{{ stats | tojson(indent=2) }}</pre>
</details>
{% endif %}
{# ── hostmap ── #}
<div class="card">
<h2>Peer Hostmap</h2>
{% if hostmap is none %}
<div class="no-data">Hostmap unavailable — stats endpoint not reachable.</div>
{% else %}
{% set hosts = hostmap.get("Hosts", {}) %}
{% if not hosts %}
<div class="no-data">No peers connected.</div>
{% else %}
<table>
<thead>
<tr>
<th>Overlay IP</th>
<th>Remote Addrs</th>
<th>Index</th>
<th>Relay?</th>
</tr>
</thead>
<tbody>
{% for overlay_ip, host in hosts.items() %}
<tr>
<td class="mono">{{ overlay_ip }}</td>
<td>
{% for addr in host.get("RemoteAddrs", []) %}
<span class="mono badge badge-gray" style="margin-right:4px;">{{ addr }}</span>
{% else %}
<span class="badge badge-gray">none</span>
{% endfor %}
</td>
<td class="mono">{{ host.get("LocalIndex", "—") }}</td>
<td>
{% if host.get("Relay") %}
<span class="badge badge-blue">relay</span>
{% else %}
<span class="badge badge-gray">no</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details style="margin-top:16px;">
<summary>Raw hostmap JSON</summary>
<pre>{{ hostmap | tojson(indent=2) }}</pre>
</details>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block content %}
<h1>Sign Certificate</h1>
<div class="card">
<h2>New Host Certificate</h2>
<p style="color:var(--muted); font-size:13px; margin-bottom:20px;">
Fill in the details below. The CA on this host will sign a new certificate.
Scan the QR codes with the mobile Nebula app or copy the PEM values.
</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/sign">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="field">
<label for="name">Host Name *</label>
<input type="text" id="name" name="name" placeholder="e.g. laptop" required
value="{{ result.name if result else '' }}">
</div>
<div class="field">
<label for="ip">Overlay IP / Mask *</label>
<input type="text" id="ip" name="ip" placeholder="e.g. 10.1.1.5/24" required
value="{{ result.ip if result else '' }}">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="field">
<label for="groups">Groups <span style="color:var(--muted)">(comma-separated, optional)</span></label>
<input type="text" id="groups" name="groups" placeholder="e.g. laptops,users">
</div>
<div class="field">
<label for="duration">Duration <span style="color:var(--muted)">(optional, e.g. 8760h0m0s)</span></label>
<input type="text" id="duration" name="duration" placeholder="default: CA lifetime">
</div>
</div>
<button type="submit" class="btn btn-primary">Sign Certificate</button>
</form>
</div>
{% if result %}
<div class="card">
<h2>Certificate Issued</h2>
{# cert info summary #}
{% if result.cert_info %}
{% set details = result.cert_info.get("details", {}) %}
<div class="stat-grid" style="margin-bottom:20px;">
<div class="stat-box">
<div class="label">Name</div>
<div class="value" style="font-size:14px; padding-top:2px;">{{ details.get("name", result.name) }}</div>
</div>
<div class="stat-box">
<div class="label">IP</div>
<div class="value" style="font-size:14px; padding-top:2px;">{{ (details.get("ips", [result.ip]) | first) }}</div>
</div>
{% if details.get("groups") %}
<div class="stat-box">
<div class="label">Groups</div>
<div class="value" style="font-size:14px; padding-top:2px;">{{ details.get("groups") | join(", ") }}</div>
</div>
{% endif %}
{% if details.get("notAfter") %}
<div class="stat-box">
<div class="label">Expires</div>
<div class="value" style="font-size:13px; padding-top:2px; word-break:break-all;">{{ details.get("notAfter") }}</div>
</div>
{% endif %}
</div>
{% endif %}
<p style="color:var(--muted); font-size:12px; margin-bottom:16px;">
Scan each QR code separately with the Nebula mobile app, or use the PEM text below.
The private key is shown only once — store it securely.
</p>
<div class="qr-grid">
<div>
<div class="qr-box">
<div class="qr-label">Host Certificate</div>
{{ result.cert_qr | safe }}
</div>
<details style="margin-top:10px;">
<summary>Certificate PEM</summary>
<pre>{{ result.cert_pem }}</pre>
</details>
</div>
<div>
<div class="qr-box">
<div class="qr-label">Host Key (Private)</div>
{{ result.key_qr | safe }}
</div>
<details style="margin-top:10px;">
<summary>Key PEM</summary>
<pre>{{ result.key_pem }}</pre>
</details>
</div>
</div>
</div>
{% endif %}
{% endblock %}