hmm
This commit is contained in:
@@ -156,9 +156,53 @@ let
|
|||||||
bouncerName = "nas-bouncer";
|
bouncerName = "nas-bouncer";
|
||||||
};
|
};
|
||||||
# secrets.apiKeyPath = config.sops.secrets."jallen-nas/crowdsec-firewall-bouncer-api-key".path;
|
# 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
|
# The upstream crowdsec module uses ReadWritePaths (not StateDirectory) on
|
||||||
# crowdsec.service, meaning it expects /var/lib/crowdsec to exist as a real
|
# crowdsec.service, meaning it expects /var/lib/crowdsec to exist as a real
|
||||||
# directory (created by tmpfiles). However, crowdsec-firewall-bouncer-register
|
# directory (created by tmpfiles). However, crowdsec-firewall-bouncer-register
|
||||||
@@ -181,7 +225,64 @@ let
|
|||||||
services = {
|
services = {
|
||||||
crowdsec = {
|
crowdsec = {
|
||||||
serviceConfig = lib.mkMerge [
|
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 != "") {
|
(lib.mkIf (cfg.ntfy.enable && cfg.ntfy.envFile != "") {
|
||||||
EnvironmentFile = [ cfg.ntfy.envFile ];
|
EnvironmentFile = [ cfg.ntfy.envFile ];
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -890,7 +890,24 @@ let
|
|||||||
restartUnits = [ "grafana.service" ];
|
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 redis exporter needs AF_INET to reach TCP Redis instances.
|
||||||
# The default systemd hardening only allows AF_UNIX.
|
# The default systemd hardening only allows AF_UNIX.
|
||||||
|
|||||||
135
modules/nixos/services/nebula-ui/default.nix
Normal file
135
modules/nixos/services/nebula-ui/default.nix
Normal 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 ];
|
||||||
|
}
|
||||||
@@ -95,6 +95,17 @@ let
|
|||||||
host = "any";
|
host = "any";
|
||||||
}
|
}
|
||||||
] "Nebula outbound firewall rules";
|
] "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 = {
|
moduleConfig = {
|
||||||
environment.systemPackages = with pkgs; [ nebula ];
|
environment.systemPackages = with pkgs; [ nebula ];
|
||||||
@@ -136,6 +147,12 @@ let
|
|||||||
inbound = cfg.inboundRules;
|
inbound = cfg.inboundRules;
|
||||||
outbound = cfg.outboundRules;
|
outbound = cfg.outboundRules;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
settings.stats = lib.mkIf cfg.stats.enable {
|
||||||
|
type = "json";
|
||||||
|
listen = "${cfg.stats.listenAddress}:${toString cfg.stats.statsPort}";
|
||||||
|
interval = "10s";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -149,6 +149,35 @@ let
|
|||||||
nextcloud-setup = {
|
nextcloud-setup = {
|
||||||
after = [ "postgresql.service" ];
|
after = [ "postgresql.service" ];
|
||||||
requires = [ "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 = {
|
nextcloud-update-db = {
|
||||||
after = [ "postgresql.service" ];
|
after = [ "postgresql.service" ];
|
||||||
|
|||||||
252
packages/system/nebula-ui/app.py
Normal file
252
packages/system/nebula-ui/app.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
52
packages/system/nebula-ui/default.nix
Normal file
52
packages/system/nebula-ui/default.nix
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
225
packages/system/nebula-ui/templates/base.html
Normal file
225
packages/system/nebula-ui/templates/base.html
Normal 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>
|
||||||
105
packages/system/nebula-ui/templates/index.html
Normal file
105
packages/system/nebula-ui/templates/index.html
Normal 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 %}
|
||||||
104
packages/system/nebula-ui/templates/sign.html
Normal file
104
packages/system/nebula-ui/templates/sign.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user