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

@@ -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 %}