{ 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 ───────────────────────────────────────────────────────── # ca-cert: already declared by the nebula module (owned by nebula-, # mode 0440). We only append restartUnits here; access is via group membership. sops.secrets."${cfg.caCertSecretKey}" = { restartUnits = [ "nebula-ui.service" ]; }; # ca-key: only used by nebula-ui, so we own it outright. sops.secrets."${cfg.caKeySecretKey}" = { sopsFile = cfg.secretsFile; owner = name; group = name; restartUnits = [ "nebula-ui.service" ]; }; # ── User / group ──────────────────────────────────────────────────────── users.users.${name} = { isSystemUser = true; group = name; # Grant read access to the nebula CA secrets (owned by nebula-) extraGroups = [ "nebula-${cfg.networkName}" ]; 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 ]; }