From e436b2f7a6cf1815cf01667bf0d07d812e82ca3c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 17:08:20 -0500 Subject: [PATCH] added service feature --- app/sovran_systemsos_web/server.py | 154 +++++++++- app/sovran_systemsos_web/static/app.js | 379 +++++++++++++++---------- modules/core/sovran-hub.nix | 17 +- 3 files changed, 386 insertions(+), 164 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index bb3e412..3143eaa 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -21,7 +21,7 @@ from fastapi.requests import Request from .config import load_config from . import systemctl as sysctl -# ── Constants ──────────────────────────────────────────────────── +# ── Constants ──────────────────────────────��───────────────────── FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" @@ -36,6 +36,17 @@ ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" REBOOT_COMMAND = ["reboot"] +# ── Tech Support constants ──────────────────────────────────────── + +SUPPORT_KEY_FILE = "/root/.ssh/sovran_support_authorized" +AUTHORIZED_KEYS = "/root/.ssh/authorized_keys" +SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status" + +# Sovran Systems tech support public key +SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyReplaceMeWithYourRealPublicKey sovran-support" + +SUPPORT_KEY_COMMENT = "sovran-support" + CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), ("bitcoin-base", "Bitcoin Base"), @@ -43,6 +54,7 @@ CATEGORY_ORDER = [ ("communication", "Communication"), ("apps", "Self-Hosted Apps"), ("nostr", "Nostr"), + ("support", "Support"), ] ROLE_LABELS = { @@ -90,7 +102,7 @@ def _file_hash(filename: str) -> str: _APP_JS_HASH = _file_hash("app.js") _STYLE_CSS_HASH = _file_hash("style.css") -# ── Update check helpers ───────────────────────────────────────── +# ── Update check helpers ──────────────────��────────────────────── def _get_locked_info(): try: @@ -291,6 +303,106 @@ def _resolve_credential(cred: dict) -> dict | None: return result +# ── Tech Support helpers ────────────────────────────────────────── + +def _is_support_active() -> bool: + """Check if the support key is currently in authorized_keys.""" + try: + with open(AUTHORIZED_KEYS, "r") as f: + content = f.read() + return SUPPORT_KEY_COMMENT in content + except FileNotFoundError: + return False + + +def _get_support_session_info() -> dict: + """Read support session metadata.""" + try: + with open(SUPPORT_STATUS_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _enable_support() -> bool: + """Add the Sovran support public key to root's authorized_keys.""" + try: + os.makedirs("/root/.ssh", mode=0o700, exist_ok=True) + + # Write the key to the dedicated support key file + with open(SUPPORT_KEY_FILE, "w") as f: + f.write(SOVRAN_SUPPORT_PUBKEY + "\n") + os.chmod(SUPPORT_KEY_FILE, 0o600) + + # Append to authorized_keys if not already present + existing = "" + try: + with open(AUTHORIZED_KEYS, "r") as f: + existing = f.read() + except FileNotFoundError: + pass + + if SUPPORT_KEY_COMMENT not in existing: + with open(AUTHORIZED_KEYS, "a") as f: + f.write(SOVRAN_SUPPORT_PUBKEY + "\n") + os.chmod(AUTHORIZED_KEYS, 0o600) + + # Write session metadata + import time + session_info = { + "enabled_at": time.time(), + "enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"), + } + os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True) + with open(SUPPORT_STATUS_FILE, "w") as f: + json.dump(session_info, f) + + return True + except Exception: + return False + + +def _disable_support() -> bool: + """Remove the Sovran support public key from authorized_keys.""" + try: + # Remove from authorized_keys + try: + with open(AUTHORIZED_KEYS, "r") as f: + lines = f.readlines() + filtered = [l for l in lines if SUPPORT_KEY_COMMENT not in l] + with open(AUTHORIZED_KEYS, "w") as f: + f.writelines(filtered) + os.chmod(AUTHORIZED_KEYS, 0o600) + except FileNotFoundError: + pass + + # Remove the dedicated key file + try: + os.remove(SUPPORT_KEY_FILE) + except FileNotFoundError: + pass + + # Remove session metadata + try: + os.remove(SUPPORT_STATUS_FILE) + except FileNotFoundError: + pass + + return True + except Exception: + return False + + +def _verify_support_removed() -> bool: + """Verify the support key is truly gone from authorized_keys.""" + try: + with open(AUTHORIZED_KEYS, "r") as f: + content = f.read() + return SUPPORT_KEY_COMMENT not in content + except FileNotFoundError: + return True # No file = no key = removed + + # ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -461,6 +573,44 @@ async def api_updates_status(offset: int = 0): } +# ── Tech Support endpoints ──────────────────────────────────────── + +@app.get("/api/support/status") +async def api_support_status(): + """Check if tech support SSH access is currently enabled.""" + loop = asyncio.get_event_loop() + active = await loop.run_in_executor(None, _is_support_active) + session = await loop.run_in_executor(None, _get_support_session_info) + return { + "active": active, + "enabled_at": session.get("enabled_at"), + "enabled_at_human": session.get("enabled_at_human"), + } + + +@app.post("/api/support/enable") +async def api_support_enable(): + """Add the Sovran support SSH key to allow remote tech support.""" + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor(None, _enable_support) + if not ok: + raise HTTPException(status_code=500, detail="Failed to enable support access") + return {"ok": True, "message": "Support access enabled"} + + +@app.post("/api/support/disable") +async def api_support_disable(): + """Remove the Sovran support SSH key and end the session.""" + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor(None, _disable_support) + if not ok: + raise HTTPException(status_code=500, detail="Failed to disable support access") + + # Verify it's actually gone + verified = await loop.run_in_executor(None, _verify_support_removed) + return {"ok": True, "verified": verified, "message": "Support access removed and verified"} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 7cce369..b6bd0f2 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,11 +1,12 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v6 — Status-only dashboard (no start/stop/restart controls) */ + v7 — Status-only dashboard + Tech Support */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts +const SUPPORT_TIMER_INTERVAL = 1000; // 1 s for session timer const CATEGORY_ORDER = [ "infrastructure", @@ -14,6 +15,7 @@ const CATEGORY_ORDER = [ "communication", "apps", "nostr", + "support", ]; const STATUS_LOADING_STATES = new Set([ @@ -22,13 +24,16 @@ const STATUS_LOADING_STATES = new Set([ // ── State ───────────────────────────────────────────────────────── -let _servicesCache = []; -let _categoryLabels = {}; -let _updateLog = ""; -let _updatePollTimer = null; -let _updateLogOffset = 0; -let _serverWasDown = false; -let _updateFinished = false; +let _servicesCache = []; +let _categoryLabels = {}; +let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; +let _serverWasDown = false; +let _updateFinished = false; +let _supportTimerInt = null; +let _supportEnabledAt = null; +let _cachedExternalIp = null; // ── DOM refs ────────────────────────────────────────────────────── @@ -54,6 +59,10 @@ const $credsTitle = document.getElementById("creds-modal-title"); const $credsBody = document.getElementById("creds-body"); const $credsCloseBtn = document.getElementById("creds-close-btn"); +const $supportModal = document.getElementById("support-modal"); +const $supportBody = document.getElementById("support-body"); +const $supportCloseBtn = document.getElementById("support-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { @@ -93,6 +102,15 @@ function linkify(str) { ); } +function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options = {}) { @@ -150,18 +168,37 @@ function buildTiles(services, categoryLabels) { } function buildTile(svc) { + const isSupport = svc.type === "support"; const sc = statusClass(svc.status); const st = statusText(svc.status, svc.enabled); const dis = !svc.enabled; const hasCreds = svc.has_credentials && svc.enabled; const tile = document.createElement("div"); - tile.className = "service-tile" + (dis ? " disabled" : ""); + tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); tile.dataset.unit = svc.unit; tile.dataset.tileId = tileId(svc); if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; - // Info button (only if service has credentials and is enabled) + if (isSupport) { + // Support tile — clickable, no info button, no status dot + tile.innerHTML = ` + ${escHtml(svc.name)} + +
${escHtml(svc.name)}
+
+ Click to manage +
+ `; + tile.style.cursor = "pointer"; + tile.addEventListener("click", () => openSupportModal()); + return tile; + } + + // Normal tile const infoBtn = hasCreds ? `` : ""; @@ -180,7 +217,6 @@ function buildTile(svc) { `; - // Info button click handler const infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { infoBtnEl.addEventListener("click", (e) => { @@ -202,6 +238,8 @@ function updateTiles(services) { const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`); if (!tile) continue; + if (svc.type === "support") continue; // Support tile doesn't have a systemd status + const sc = statusClass(svc.status); const st = statusText(svc.status, svc.enabled); @@ -238,6 +276,7 @@ async function loadNetwork() { const data = await apiFetch("/api/network"); if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; if ($externalIp) $externalIp.textContent = data.external_ip || "—"; + _cachedExternalIp = data.external_ip || "unavailable"; } catch (_) { if ($internalIp) $internalIp.textContent = "—"; if ($externalIp) $externalIp.textContent = "—"; @@ -282,7 +321,6 @@ async function openCredsModal(unit, name) { const id = "cred-" + Math.random().toString(36).substring(2, 8); const displayValue = linkify(cred.value); - // QR code block (if present) let qrBlock = ""; if (cred.qrcode) { qrBlock = ` @@ -306,7 +344,6 @@ async function openCredsModal(unit, name) { } $credsBody.innerHTML = html; - // Attach copy handlers $credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => { btn.addEventListener("click", () => { const target = document.getElementById(btn.dataset.target); @@ -332,6 +369,184 @@ function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } +// ── Tech Support modal ──────────────────────────────────────────── + +async function openSupportModal() { + if (!$supportModal) return; + $supportModal.classList.add("open"); + $supportBody.innerHTML = '

Checking support status…

'; + + try { + const status = await apiFetch("/api/support/status"); + if (status.active) { + _supportEnabledAt = status.enabled_at; + renderSupportActive(); + } else { + renderSupportInactive(); + } + } catch (err) { + $supportBody.innerHTML = '

Could not check support status.

'; + } +} + +function renderSupportInactive() { + stopSupportTimer(); + const ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = ` +
+
🛟
+

Need help from Sovran Systems?

+

+ This will temporarily give Sovran Systems secure SSH access to your machine + so we can diagnose and fix issues for you. +

+ +
+
+ Your External IP + ${escHtml(ip)} +
+

+ Give this IP to your Sovran Systems technician when asked. +

+
+ +
+

What happens when you click Enable:

+
    +
  1. A Sovran Systems SSH key is added to this machine
  2. +
  3. You give us your External IP shown above
  4. +
  5. We connect and help you remotely
  6. +
  7. When done, you click End Support Session to remove the key
  8. +
+
+ + +

+ You can end the session at any time. The access key will be completely removed. +

+
+ `; + + document.getElementById("btn-support-enable").addEventListener("click", enableSupport); +} + +function renderSupportActive() { + const ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = ` +
+
🔓
+

Support Access is Active

+

+ Sovran Systems can currently connect to your machine via SSH. +

+ +
+
+ Your External IP + ${escHtml(ip)} +
+
+ Session Duration + +
+
+ +

+ When your support session is complete, click the button below to + immediately remove the access key. +

+ + +
+ `; + + document.getElementById("btn-support-disable").addEventListener("click", disableSupport); + startSupportTimer(); +} + +function renderSupportRemoved(verified) { + stopSupportTimer(); + const icon = verified ? "✅" : "⚠️"; + const msg = verified + ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." + : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure."; + + $supportBody.innerHTML = ` +
+
${icon}
+

Support Session Ended

+

${escHtml(msg)}

+ +
+ SSH Key Status: + + ${verified ? "✓ Removed — No access" : "⚠ Verify by rebooting"} + +
+ + +
+ `; + + document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); +} + +async function enableSupport() { + const btn = document.getElementById("btn-support-enable"); + if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } + try { + await apiFetch("/api/support/enable", { method: "POST" }); + const status = await apiFetch("/api/support/status"); + _supportEnabledAt = status.enabled_at; + renderSupportActive(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } + alert("Failed to enable support access. Please try again."); + } +} + +async function disableSupport() { + const btn = document.getElementById("btn-support-disable"); + if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } + try { + const result = await apiFetch("/api/support/disable", { method: "POST" }); + renderSupportRemoved(result.verified); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } + alert("Failed to disable support access. Please try again."); + } +} + +function startSupportTimer() { + stopSupportTimer(); + updateSupportTimer(); + _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); +} + +function stopSupportTimer() { + if (_supportTimerInt) { + clearInterval(_supportTimerInt); + _supportTimerInt = null; + } +} + +function updateSupportTimer() { + const el = document.getElementById("support-timer"); + if (!el || !_supportEnabledAt) return; + const elapsed = (Date.now() / 1000) - _supportEnabledAt; + el.textContent = formatDuration(Math.max(0, elapsed)); +} + +function closeSupportModal() { + if ($supportModal) $supportModal.classList.remove("open"); + stopSupportTimer(); +} + // ── Update modal ────────────────────────────────────────────────── function openUpdateModal() { @@ -394,140 +609,4 @@ function startUpdatePoll() { 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() { - if ($modal) $modal.classList.remove("open"); - stopUpdatePoll(); - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - fetch("/api/reboot", { method: "POST" }).catch(() => {}); - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(res => { - if (res.ok) { - window.location.reload(); - } else { - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - } - }) - .catch(() => { - 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 ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); - -if ($modal) { - $modal.addEventListener("click", (e) => { - if (e.target === $modal) closeUpdateModal(); - }); -} - -if ($credsModal) { - $credsModal.addEventListener("click", (e) => { - if (e.target === $credsModal) closeCredsModal(); - }); -} - -// ── 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 + clearInterval(_ \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 884a94f..f8f7923 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -51,7 +51,7 @@ let ]; } { name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; } - { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } + { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus �� Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } @@ -82,6 +82,10 @@ let # ── Nostr / Relay ────────────────────────────────────────── ++ [ { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; } + ] + # ── Support ──────────────────────────────────────────────── + ++ [ + { name = "Tech Support"; unit = "sovran-tech-support"; type = "support"; icon = "support"; enabled = true; category = "support"; credentials = []; } ]; activeRole = @@ -105,10 +109,7 @@ let LOG="/var/log/sovran-hub-update.log" STATUS="/var/log/sovran-hub-update.status" - # Mark as RUNNING echo "RUNNING" > "$STATUS" - - # Truncate the log and redirect ALL output (stdout + stderr) into it : > "$LOG" exec > >(tee -a "$LOG") 2>&1 @@ -177,18 +178,14 @@ let installPhase = '' runHook preInstall - # ── Python source ───────────────────────────────────────── install -d $out/lib/sovran-hub-web cp -r sovran_systemsos_web $out/lib/sovran-hub-web/ - # ── Generated config ─────────────────────────────────────── cp ${generatedConfig} $out/lib/sovran-hub-web/config.json - # ── Icons (SVG) ──────────────────��───────────────────────── install -d $out/share/sovran-hub/icons cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true - # ── Launcher script ──────────────────────────────────────── install -d $out/bin cat > $out/bin/sovran-hub-web <