From 3234029ae5905c6259061fd90a20403ec1b21bdd Mon Sep 17 00:00:00 2001 From: mjallen18 Date: Tue, 7 Apr 2026 20:36:32 -0500 Subject: [PATCH] hmm --- modules/nixos/services/crowdsec/default.nix | 103 ++++++- modules/nixos/services/grafana/default.nix | 19 +- modules/nixos/services/nebula-ui/default.nix | 135 ++++++++++ modules/nixos/services/nebula/default.nix | 17 ++ modules/nixos/services/nextcloud/default.nix | 29 ++ packages/system/nebula-ui/app.py | 252 ++++++++++++++++++ packages/system/nebula-ui/default.nix | 52 ++++ packages/system/nebula-ui/templates/base.html | 225 ++++++++++++++++ .../system/nebula-ui/templates/index.html | 105 ++++++++ packages/system/nebula-ui/templates/sign.html | 104 ++++++++ 10 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 modules/nixos/services/nebula-ui/default.nix create mode 100644 packages/system/nebula-ui/app.py create mode 100644 packages/system/nebula-ui/default.nix create mode 100644 packages/system/nebula-ui/templates/base.html create mode 100644 packages/system/nebula-ui/templates/index.html create mode 100644 packages/system/nebula-ui/templates/sign.html diff --git a/modules/nixos/services/crowdsec/default.nix b/modules/nixos/services/crowdsec/default.nix index 37bfca3..bb38bf5 100755 --- a/modules/nixos/services/crowdsec/default.nix +++ b/modules/nixos/services/crowdsec/default.nix @@ -156,9 +156,53 @@ 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 # directory (created by tmpfiles). However, crowdsec-firewall-bouncer-register @@ -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 ]; }) diff --git a/modules/nixos/services/grafana/default.nix b/modules/nixos/services/grafana/default.nix index 490e21f..af09b72 100755 --- a/modules/nixos/services/grafana/default.nix +++ b/modules/nixos/services/grafana/default.nix @@ -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. diff --git a/modules/nixos/services/nebula-ui/default.nix b/modules/nixos/services/nebula-ui/default.nix new file mode 100644 index 0000000..f04895c --- /dev/null +++ b/modules/nixos/services/nebula-ui/default.nix @@ -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-. + 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 ]; +} diff --git a/modules/nixos/services/nebula/default.nix b/modules/nixos/services/nebula/default.nix index e44ef3d..6a0077d 100755 --- a/modules/nixos/services/nebula/default.nix +++ b/modules/nixos/services/nebula/default.nix @@ -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"; + }; }; }; }; diff --git a/modules/nixos/services/nextcloud/default.nix b/modules/nixos/services/nextcloud/default.nix index 83db13d..34c0a51 100755 --- a/modules/nixos/services/nextcloud/default.nix +++ b/modules/nixos/services/nextcloud/default.nix @@ -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+ - - - - " + 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" ]; diff --git a/packages/system/nebula-ui/app.py b/packages/system/nebula-ui/app.py new file mode 100644 index 0000000..429c421 --- /dev/null +++ b/packages/system/nebula-ui/app.py @@ -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, + }, + ) diff --git a/packages/system/nebula-ui/default.nix b/packages/system/nebula-ui/default.nix new file mode 100644 index 0000000..be37923 --- /dev/null +++ b/packages/system/nebula-ui/default.nix @@ -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; + }; +} diff --git a/packages/system/nebula-ui/templates/base.html b/packages/system/nebula-ui/templates/base.html new file mode 100644 index 0000000..8b4a31c --- /dev/null +++ b/packages/system/nebula-ui/templates/base.html @@ -0,0 +1,225 @@ + + + + + + Nebula UI — {{ network_name }} + + + +
+ + + {{ network_name }} +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/packages/system/nebula-ui/templates/index.html b/packages/system/nebula-ui/templates/index.html new file mode 100644 index 0000000..e62f6c3 --- /dev/null +++ b/packages/system/nebula-ui/templates/index.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% block content %} +

Network Overview

+ +{% if stats is none %} +
+
+ Could not reach the Nebula stats endpoint at {{ request.app.state.stats_url if request.app.state is defined else "configured URL" }}. + Make sure stats.enabled and stats.listen are set in your Nebula config. +
+
+{% else %} + + {# ── top-level counters ── #} + {% set meta = stats.get("meta", {}) %} + {% set network = stats.get("network", {}) %} + +
+
+
Nebula Version
+
{{ meta.get("version", "—") }}
+
+ {% set peers = hostmap.get("Hosts", {}) if hostmap else {} %} +
+
Active Peers
+
{{ peers | length }}
+
+ {% set counters = stats.get("counters", {}) %} +
+
Tx Bytes
+
{{ "{:,}".format(counters.get("send_bytes", 0)) }}
+
+
+
Rx Bytes
+
{{ "{:,}".format(counters.get("recv_bytes", 0)) }}
+
+
+
Tx Packets
+
{{ "{:,}".format(counters.get("send_packets", 0)) }}
+
+
+
Rx Packets
+
{{ "{:,}".format(counters.get("recv_packets", 0)) }}
+
+
+ + {# ── raw stats JSON ── #} +
+ Raw stats JSON +
{{ stats | tojson(indent=2) }}
+
+ +{% endif %} + +{# ── hostmap ── #} +
+

Peer Hostmap

+ {% if hostmap is none %} +
Hostmap unavailable — stats endpoint not reachable.
+ {% else %} + {% set hosts = hostmap.get("Hosts", {}) %} + {% if not hosts %} +
No peers connected.
+ {% else %} + + + + + + + + + + + {% for overlay_ip, host in hosts.items() %} + + + + + + + {% endfor %} + +
Overlay IPRemote AddrsIndexRelay?
{{ overlay_ip }} + {% for addr in host.get("RemoteAddrs", []) %} + {{ addr }} + {% else %} + none + {% endfor %} + {{ host.get("LocalIndex", "—") }} + {% if host.get("Relay") %} + relay + {% else %} + no + {% endif %} +
+ {% endif %} +
+ Raw hostmap JSON +
{{ hostmap | tojson(indent=2) }}
+
+ {% endif %} +
+ +{% endblock %} diff --git a/packages/system/nebula-ui/templates/sign.html b/packages/system/nebula-ui/templates/sign.html new file mode 100644 index 0000000..f2562a5 --- /dev/null +++ b/packages/system/nebula-ui/templates/sign.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block content %} +

Sign Certificate

+ +
+

New Host Certificate

+

+ 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. +

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +{% if result %} +
+

Certificate Issued

+ + {# cert info summary #} + {% if result.cert_info %} + {% set details = result.cert_info.get("details", {}) %} +
+
+
Name
+
{{ details.get("name", result.name) }}
+
+
+
IP
+
{{ (details.get("ips", [result.ip]) | first) }}
+
+ {% if details.get("groups") %} +
+
Groups
+
{{ details.get("groups") | join(", ") }}
+
+ {% endif %} + {% if details.get("notAfter") %} +
+
Expires
+
{{ details.get("notAfter") }}
+
+ {% endif %} +
+ {% endif %} + +

+ 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. +

+ +
+
+
+
Host Certificate
+ {{ result.cert_qr | safe }} +
+
+ Certificate PEM +
{{ result.cert_pem }}
+
+
+
+
+
Host Key (Private)
+ {{ result.key_qr | safe }} +
+
+ Key PEM +
{{ result.key_pem }}
+
+
+
+
+{% endif %} + +{% endblock %}