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 = `
+
+
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 = ` ++ This will temporarily give Sovran Systems secure SSH access to your machine + so we can diagnose and fix issues for you. +
+ ++ Give this IP to your Sovran Systems technician when asked. +
+What happens when you click Enable:
++ You can end the session at any time. The access key will be completely removed. +
++ Sovran Systems can currently connect to your machine via SSH. +
+ ++ When your support session is complete, click the button below to + immediately remove the access key. +
+ + +${escHtml(msg)}
+ +