diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 238e5c3..2034a0f 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import os +import re import socket import subprocess import urllib.request @@ -192,6 +193,43 @@ def _read_log(offset: int = 0) -> tuple[str, int]: return "", 0 +# ── Credentials helpers ────────────────────────────────────────── + +def _resolve_credential(cred: dict) -> dict | None: + """Resolve a single credential entry to {label, value}.""" + label = cred.get("label", "") + prefix = cred.get("prefix", "") + suffix = cred.get("suffix", "") + extract = cred.get("extract", "") + multiline = cred.get("multiline", False) + + # Static value + if "value" in cred: + return {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline} + + # File-based value + filepath = cred.get("file", "") + if not filepath: + return None + + try: + with open(filepath, "r") as f: + raw = f.read().strip() + except (FileNotFoundError, PermissionError): + return None + + if extract: + # Extract a key=value from an env file (e.g., ADMIN_TOKEN=...) + match = re.search(rf'{re.escape(extract)}=(.*)', raw) + if match: + raw = match.group(1).strip() + else: + return None + + value = prefix + raw + suffix + return {"label": label, "value": value, "multiline": multiline} + + # ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -228,6 +266,10 @@ async def api_services(): ) else: status = "disabled" + + creds = entry.get("credentials", []) + has_credentials = len(creds) > 0 + return { "name": entry.get("name", ""), "unit": unit, @@ -236,12 +278,44 @@ async def api_services(): "enabled": enabled, "category": entry.get("category", "other"), "status": status, + "has_credentials": has_credentials, } results = await asyncio.gather(*[get_status(s) for s in services]) return list(results) +@app.get("/api/credentials/{unit}") +async def api_credentials(unit: str): + """Return resolved credentials for a given service unit.""" + cfg = load_config() + services = cfg.get("services", []) + + # Find the service entry matching this unit + entry = None + for s in services: + if s.get("unit") == unit: + creds = s.get("credentials", []) + if creds: + entry = s + break + + if not entry: + raise HTTPException(status_code=404, detail="No credentials for this service") + + loop = asyncio.get_event_loop() + resolved = [] + for cred in entry.get("credentials", []): + result = await loop.run_in_executor(None, _resolve_credential, cred) + if result: + resolved.append(result) + + return { + "name": entry.get("name", ""), + "credentials": resolved, + } + + def _get_allowed_units() -> set[str]: cfg = load_config() return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")} diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 1bd498b..3120df6 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -20,7 +20,7 @@ const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); -// ── State ──────────────────────────────────────────────���────────── +// ── State ───────────────────────────────────────────────────────── let _servicesCache = []; let _categoryLabels = {}; @@ -49,6 +49,11 @@ const $btnCloseModal = document.getElementById("btn-close-modal"); const $rebootOverlay = document.getElementById("reboot-overlay"); +const $credsModal = document.getElementById("creds-modal"); +const $credsTitle = document.getElementById("creds-modal-title"); +const $credsBody = document.getElementById("creds-body"); +const $credsCloseBtn = document.getElementById("creds-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function statusClass(status) { @@ -102,366 +107,4 @@ function buildTiles(services, categoryLabels) { const section = document.createElement("div"); section.className = "category-section"; - section.dataset.category = catKey; - - section.innerHTML = ` -
${escHtml(label)}
-
-
- `; - - const grid = section.querySelector(".tiles-grid"); - for (const svc of entries) { - grid.appendChild(buildTile(svc)); - } - - $tilesArea.appendChild(section); - } - - if ($tilesArea.children.length === 0) { - $tilesArea.innerHTML = `

No services configured.

`; - } -} - -function buildTile(svc) { - const sc = statusClass(svc.status); - const st = statusText(svc.status, svc.enabled); - const dis = !svc.enabled; - const isOn = svc.status === "active"; - - const tile = document.createElement("div"); - tile.className = "service-tile" + (dis ? " disabled" : ""); - tile.dataset.unit = svc.unit; - if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; - - tile.innerHTML = ` - ${escHtml(svc.name)} - -
${escHtml(svc.name)}
-
- - ${escHtml(st)} -
-
-
- - -
- `; - - const chk = tile.querySelector(".tile-toggle"); - if (!dis) { - chk.addEventListener("change", async (e) => { - const action = e.target.checked ? "start" : "stop"; - chk.disabled = true; - try { - await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" }); - } catch (_) {} - setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); - }); - } - - const restartBtn = tile.querySelector(".tile-restart-btn"); - if (!dis) { - restartBtn.addEventListener("click", async () => { - restartBtn.disabled = true; - try { - await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" }); - } catch (_) {} - setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); - }); - } - - return tile; -} - -// ── Render: live update (no DOM rebuild) ────────────────────────── - -function updateTiles(services) { - _servicesCache = services; - - for (const svc of services) { - const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`); - if (!tile) continue; - - const sc = statusClass(svc.status); - const st = statusText(svc.status, svc.enabled); - - const dot = tile.querySelector(".status-dot"); - const text = tile.querySelector(".status-text"); - const chk = tile.querySelector(".tile-toggle"); - - if (dot) { dot.className = `status-dot ${sc}`; } - if (text) { text.textContent = st; } - if (chk && !chk.disabled) { - chk.checked = svc.status === "active"; - } - } -} - -// ── HTML escape ─────────────────────────────────────────────────── - -function escHtml(str) { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// ── Service polling ─────────────────────────────────────────────── - -let _firstLoad = true; - -async function refreshServices() { - try { - const services = await apiFetch("/api/services"); - if (_firstLoad) { - buildTiles(services, _categoryLabels); - _firstLoad = false; - } else { - updateTiles(services); - } - } catch (err) { - console.warn("Failed to fetch services:", err); - } -} - -// ── Network IPs ─────────────────────────────────────────────────── - -async function loadNetwork() { - try { - const data = await apiFetch("/api/network"); - if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; - if ($externalIp) $externalIp.textContent = data.external_ip || "—"; - } catch (_) { - if ($internalIp) $internalIp.textContent = "—"; - if ($externalIp) $externalIp.textContent = "—"; - } -} - -// ── Update check ────────────────────────────────────────────────── - -async function checkUpdates() { - try { - const data = await apiFetch("/api/updates/check"); - const hasUpdates = !!data.available; - if ($updateBadge) { - $updateBadge.classList.toggle("visible", hasUpdates); - } - if ($updateBtn) { - $updateBtn.classList.toggle("has-updates", hasUpdates); - } - } catch (_) {} -} - -// ── Update modal ────────────────────────────────────────────────── - -function openUpdateModal() { - if (!$modal) return; - _updateLog = ""; - _updateLogOffset = 0; - _serverWasDown = false; - _updateFinished = false; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Starting update…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) { $btnReboot.style.display = "none"; } - if ($btnSave) { $btnSave.style.display = "none"; } - if ($btnCloseModal) { $btnCloseModal.disabled = true; } - - $modal.classList.add("open"); - startUpdate(); -} - -function closeUpdateModal() { - if (!$modal) return; - $modal.classList.remove("open"); - stopUpdatePoll(); -} - -function appendLog(text) { - if (!text) return; - _updateLog += text; - if ($modalLog) { - $modalLog.textContent += text; - $modalLog.scrollTop = $modalLog.scrollHeight; - } -} - -function startUpdate() { - fetch("/api/updates/run", { method: "POST" }) - .then(response => { - if (!response.ok) { - return response.text().then(t => { throw new Error(t); }); - } - return response.json(); - }) - .then(data => { - if (data.status === "already_running") { - appendLog("[Update already in progress, attaching…]\n\n"); - } - if ($modalStatus) $modalStatus.textContent = "Updating…"; - startUpdatePoll(); - }) - .catch(err => { - appendLog(`[Error: failed to start update — ${err}]\n`); - onUpdateDone(false); - }); -} - -function startUpdatePoll() { - pollUpdateStatus(); - _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); -} - -function stopUpdatePoll() { - if (_updatePollTimer) { - clearInterval(_updatePollTimer); - _updatePollTimer = null; - } -} - -async function pollUpdateStatus() { - if (_updateFinished) return; - - try { - const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); - - if (_serverWasDown) { - _serverWasDown = false; - appendLog("[Server reconnected]\n"); - if ($modalStatus) $modalStatus.textContent = "Updating…"; - } - - if (data.log) { - appendLog(data.log); - } - _updateLogOffset = data.offset; - - if (data.running) { - return; - } - - _updateFinished = true; - stopUpdatePoll(); - - if (data.result === "success") { - onUpdateDone(true); - } else { - onUpdateDone(false); - } - } catch (err) { - if (!_serverWasDown) { - _serverWasDown = true; - appendLog("\n[Server restarting — waiting for it to come back…]\n"); - if ($modalStatus) $modalStatus.textContent = "Server restarting…"; - } - } -} - -function onUpdateDone(success) { - if ($modalSpinner) $modalSpinner.classList.remove("spinning"); - if ($btnCloseModal) $btnCloseModal.disabled = false; - - if (success) { - if ($modalStatus) $modalStatus.textContent = "✓ Update complete"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } else { - if ($modalStatus) $modalStatus.textContent = "✗ Update failed"; - if ($btnSave) $btnSave.style.display = "inline-flex"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } -} - -function saveErrorReport() { - const blob = new Blob([_updateLog], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Reboot with confirmation overlay ────────────────────────────── - -function doReboot() { - // Close the update modal - if ($modal) $modal.classList.remove("open"); - stopUpdatePoll(); - - // Show the reboot overlay - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - - // Send the reboot command - fetch("/api/reboot", { method: "POST" }).catch(() => {}); - - // Start polling to detect when the server comes back - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(res => { - if (res.ok) { - // Server is back — reload the page to get the fresh state - window.location.reload(); - } else { - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - } - }) - .catch(() => { - // Still down — keep trying - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - }); -} - -// ── Event listeners ─────────────────────────────────────────────── - -if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); -if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices()); -if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); -if ($btnReboot) $btnReboot.addEventListener("click", doReboot); -if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); - -if ($modal) { - $modal.addEventListener("click", (e) => { - if (e.target === $modal) closeUpdateModal(); - }); -} - -// ── Init ────────────────────────────────────────────────────────── - -async function init() { - try { - const cfg = await apiFetch("/api/config"); - if (cfg.category_order) { - for (const [key, label] of cfg.category_order) { - _categoryLabels[key] = label; - } - } - const badge = document.getElementById("role-badge"); - if (badge && cfg.role_label) badge.textContent = cfg.role_label; - } catch (_) {} - - await refreshServices(); - loadNetwork(); - checkUpdates(); - - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); -} - -document.addEventListener("DOMContentLoaded", init); \ No newline at end of file + section.dataset.category \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index fcc8606..62eeee2 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,6 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet Dark theme matching the Adwaita dark aesthetic - v3 — reboot overlay */ + v4 — credentials info modal */ *, *::before, *::after { box-sizing: border-box; @@ -70,7 +70,7 @@ body { letter-spacing: 0.03em; } -/* ── Buttons ────────────────────────────────────────────────────��─ */ +/* ── Buttons ────────────────────────────────────────────────────── */ button { font-family: inherit; @@ -249,6 +249,32 @@ button:disabled { opacity: 0.45; } +/* Info badge on tiles with credentials */ +.tile-info-btn { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.75rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + transition: transform 0.15s, background-color 0.15s; + line-height: 1; +} + +.tile-info-btn:hover { + transform: scale(1.15); + background-color: #a8c8ff; +} + .tile-icon { width: 48px; height: 48px; @@ -504,6 +530,133 @@ button.btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } +/* ── Credentials info modal ──────────────────────────────────────── */ + +.creds-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 520px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); + animation: creds-fade-in 0.2s ease-out; +} + +@keyframes creds-fade-in { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.creds-header { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); +} + +.creds-title { + font-size: 1rem; + font-weight: 700; + flex: 1; +} + +.creds-close-btn { + background: none; + color: var(--text-secondary); + font-size: 1.1rem; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + border: none; +} + +.creds-close-btn:hover { + background-color: var(--border-color); + color: var(--text-primary); +} + +.creds-body { + padding: 16px 20px; + overflow-y: auto; +} + +.creds-loading { + color: var(--text-dim); + text-align: center; + padding: 24px 0; +} + +.creds-row { + margin-bottom: 14px; +} + +.creds-row:last-child { + margin-bottom: 0; +} + +.creds-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 4px; +} + +.creds-value-wrap { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.creds-value { + flex: 1; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.82rem; + color: var(--accent-color); + background-color: #12121c; + padding: 8px 12px; + border-radius: 8px; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.5; + border: 1px solid var(--border-color); +} + +.creds-copy-btn { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.72rem; + font-weight: 600; + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + border: none; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + margin-top: 6px; +} + +.creds-copy-btn:hover { + background-color: #5a5c72; +} + +.creds-copy-btn.copied { + background-color: var(--green); + color: #fff; +} + +.creds-empty { + color: var(--text-dim); + text-align: center; + padding: 24px 0; + font-size: 0.88rem; +} + /* ── Reboot overlay ─────────────────────────────────────────────── */ .reboot-overlay { @@ -634,4 +787,7 @@ button.btn-reboot:hover:not(:disabled) { padding: 36px 28px; margin: 0 16px; } + .creds-dialog { + margin: 0 12px; + } } \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index e9144cd..e7f0f7e 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,7 @@ Sovran_SystemsOS Hub - + @@ -54,6 +54,19 @@ + + +
@@ -72,6 +85,6 @@
- + \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 3586db2..1e3ddd4 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -6,37 +6,62 @@ let monitoredServices = # ── Infrastructure (always present) ──────────────────────── [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; } - { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; } + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } ] # ── Bitcoin Base (node implementations) ──────────────────── ++ [ - { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; } - { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; } - { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; } + { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + ]; } + { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + ]; } + { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + ]; } ] # ── Bitcoin Apps (services on top of the node) ───────────── ++ [ - { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; } + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; } + { label = "Port"; value = "50001"; } + ]; } + { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; } + { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "URL"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; } + { label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; } + ]; } + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; } + { label = "Note"; value = "Create your admin account on first visit"; } + ]; } + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = []; } ] # ── Communication ────────────────────────────────────────── ++ [ - { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } - { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } + { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [ + { label = "Users"; file = "/var/lib/secrets/matrix-users"; multiline = true; } + ]; } + { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; } ] # ── Self-Hosted Apps ─────────────────────────────────────── ++ [ - { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; } - { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; } - { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; category = "apps"; } + { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; credentials = [ + { label = "URL"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; } + { label = "Admin Panel"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; suffix = "/admin"; } + { label = "Admin Token"; file = "/var/lib/secrets/vaultwarden/vaultwarden.env"; extract = "ADMIN_TOKEN"; } + ]; } + { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; credentials = [ + { label = "Credentials"; file = "/var/lib/secrets/nextcloud-admin"; multiline = true; } + ]; } + { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; category = "apps"; credentials = [ + { label = "Credentials"; file = "/var/lib/secrets/wordpress-admin"; multiline = true; } + ]; } ] # ── Nostr / Relay ────────────────────────────────────────── ++ [ - { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; } + { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; } ]; activeRole = @@ -52,7 +77,7 @@ let services = monitoredServices; }); - # ── Update wrapper script ────────────────────────────────────── + # ── Update wrapper script ─────────────────────────────────────�� update-script = pkgs.writeShellScript "sovran-hub-update.sh" '' set -uo pipefail export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH"