added service feature
This commit is contained in:
@@ -21,7 +21,7 @@ from fastapi.requests import Request
|
|||||||
from .config import load_config
|
from .config import load_config
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
|
|
||||||
# ── Constants ────────────────────────────────────────────────────
|
# ── Constants ──────────────────────────────<EFBFBD><EFBFBD>─────────────────────
|
||||||
|
|
||||||
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
|
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
|
||||||
FLAKE_INPUT_NAME = "Sovran_Systems"
|
FLAKE_INPUT_NAME = "Sovran_Systems"
|
||||||
@@ -36,6 +36,17 @@ ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
|||||||
|
|
||||||
REBOOT_COMMAND = ["reboot"]
|
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 = [
|
CATEGORY_ORDER = [
|
||||||
("infrastructure", "Infrastructure"),
|
("infrastructure", "Infrastructure"),
|
||||||
("bitcoin-base", "Bitcoin Base"),
|
("bitcoin-base", "Bitcoin Base"),
|
||||||
@@ -43,6 +54,7 @@ CATEGORY_ORDER = [
|
|||||||
("communication", "Communication"),
|
("communication", "Communication"),
|
||||||
("apps", "Self-Hosted Apps"),
|
("apps", "Self-Hosted Apps"),
|
||||||
("nostr", "Nostr"),
|
("nostr", "Nostr"),
|
||||||
|
("support", "Support"),
|
||||||
]
|
]
|
||||||
|
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
@@ -90,7 +102,7 @@ def _file_hash(filename: str) -> str:
|
|||||||
_APP_JS_HASH = _file_hash("app.js")
|
_APP_JS_HASH = _file_hash("app.js")
|
||||||
_STYLE_CSS_HASH = _file_hash("style.css")
|
_STYLE_CSS_HASH = _file_hash("style.css")
|
||||||
|
|
||||||
# ── Update check helpers ─────────────────────────────────────────
|
# ── Update check helpers ──────────────────<EFBFBD><EFBFBD>──────────────────────
|
||||||
|
|
||||||
def _get_locked_info():
|
def _get_locked_info():
|
||||||
try:
|
try:
|
||||||
@@ -291,6 +303,106 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
return result
|
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 ───────────────────────────────────────────────────────
|
# ── Routes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@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 ───────────────
|
# ── Startup: seed the internal IP file immediately ───────────────
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||||
v6 — Status-only dashboard (no start/stop/restart controls) */
|
v7 — Status-only dashboard + Tech Support */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const POLL_INTERVAL_SERVICES = 5000; // 5 s
|
const POLL_INTERVAL_SERVICES = 5000; // 5 s
|
||||||
const POLL_INTERVAL_UPDATES = 1800000; // 30 min
|
const POLL_INTERVAL_UPDATES = 1800000; // 30 min
|
||||||
const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running
|
const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running
|
||||||
const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts
|
const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts
|
||||||
|
const SUPPORT_TIMER_INTERVAL = 1000; // 1 s for session timer
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
@@ -14,6 +15,7 @@ const CATEGORY_ORDER = [
|
|||||||
"communication",
|
"communication",
|
||||||
"apps",
|
"apps",
|
||||||
"nostr",
|
"nostr",
|
||||||
|
"support",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_LOADING_STATES = new Set([
|
const STATUS_LOADING_STATES = new Set([
|
||||||
@@ -22,13 +24,16 @@ const STATUS_LOADING_STATES = new Set([
|
|||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _servicesCache = [];
|
let _servicesCache = [];
|
||||||
let _categoryLabels = {};
|
let _categoryLabels = {};
|
||||||
let _updateLog = "";
|
let _updateLog = "";
|
||||||
let _updatePollTimer = null;
|
let _updatePollTimer = null;
|
||||||
let _updateLogOffset = 0;
|
let _updateLogOffset = 0;
|
||||||
let _serverWasDown = false;
|
let _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
let _updateFinished = false;
|
||||||
|
let _supportTimerInt = null;
|
||||||
|
let _supportEnabledAt = null;
|
||||||
|
let _cachedExternalIp = null;
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,6 +59,10 @@ const $credsTitle = document.getElementById("creds-modal-title");
|
|||||||
const $credsBody = document.getElementById("creds-body");
|
const $credsBody = document.getElementById("creds-body");
|
||||||
const $credsCloseBtn = document.getElementById("creds-close-btn");
|
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 ───────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function tileId(svc) {
|
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 ────────────────────────────────────────────────
|
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch(path, options = {}) {
|
async function apiFetch(path, options = {}) {
|
||||||
@@ -150,18 +168,37 @@ function buildTiles(services, categoryLabels) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTile(svc) {
|
function buildTile(svc) {
|
||||||
|
const isSupport = svc.type === "support";
|
||||||
const sc = statusClass(svc.status);
|
const sc = statusClass(svc.status);
|
||||||
const st = statusText(svc.status, svc.enabled);
|
const st = statusText(svc.status, svc.enabled);
|
||||||
const dis = !svc.enabled;
|
const dis = !svc.enabled;
|
||||||
const hasCreds = svc.has_credentials && svc.enabled;
|
const hasCreds = svc.has_credentials && svc.enabled;
|
||||||
|
|
||||||
const tile = document.createElement("div");
|
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.unit = svc.unit;
|
||||||
tile.dataset.tileId = tileId(svc);
|
tile.dataset.tileId = tileId(svc);
|
||||||
if (dis) tile.title = `${svc.name} is not enabled in custom.nix`;
|
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 = `
|
||||||
|
<img class="tile-icon"
|
||||||
|
src="/static/icons/${escHtml(svc.icon)}.svg"
|
||||||
|
alt="${escHtml(svc.name)}"
|
||||||
|
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
|
||||||
|
<div class="tile-icon-fallback" style="display:none">🛟</div>
|
||||||
|
<div class="tile-name">${escHtml(svc.name)}</div>
|
||||||
|
<div class="tile-status">
|
||||||
|
<span class="support-status-label">Click to manage</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
tile.style.cursor = "pointer";
|
||||||
|
tile.addEventListener("click", () => openSupportModal());
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal tile
|
||||||
const infoBtn = hasCreds
|
const infoBtn = hasCreds
|
||||||
? `<button class="tile-info-btn" data-unit="${escHtml(svc.unit)}" title="Connection info">i</button>`
|
? `<button class="tile-info-btn" data-unit="${escHtml(svc.unit)}" title="Connection info">i</button>`
|
||||||
: "";
|
: "";
|
||||||
@@ -180,7 +217,6 @@ function buildTile(svc) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Info button click handler
|
|
||||||
const infoBtnEl = tile.querySelector(".tile-info-btn");
|
const infoBtnEl = tile.querySelector(".tile-info-btn");
|
||||||
if (infoBtnEl) {
|
if (infoBtnEl) {
|
||||||
infoBtnEl.addEventListener("click", (e) => {
|
infoBtnEl.addEventListener("click", (e) => {
|
||||||
@@ -202,6 +238,8 @@ function updateTiles(services) {
|
|||||||
const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`);
|
const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`);
|
||||||
if (!tile) continue;
|
if (!tile) continue;
|
||||||
|
|
||||||
|
if (svc.type === "support") continue; // Support tile doesn't have a systemd status
|
||||||
|
|
||||||
const sc = statusClass(svc.status);
|
const sc = statusClass(svc.status);
|
||||||
const st = statusText(svc.status, svc.enabled);
|
const st = statusText(svc.status, svc.enabled);
|
||||||
|
|
||||||
@@ -238,6 +276,7 @@ async function loadNetwork() {
|
|||||||
const data = await apiFetch("/api/network");
|
const data = await apiFetch("/api/network");
|
||||||
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
||||||
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
||||||
|
_cachedExternalIp = data.external_ip || "unavailable";
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if ($internalIp) $internalIp.textContent = "—";
|
if ($internalIp) $internalIp.textContent = "—";
|
||||||
if ($externalIp) $externalIp.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 id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||||
const displayValue = linkify(cred.value);
|
const displayValue = linkify(cred.value);
|
||||||
|
|
||||||
// QR code block (if present)
|
|
||||||
let qrBlock = "";
|
let qrBlock = "";
|
||||||
if (cred.qrcode) {
|
if (cred.qrcode) {
|
||||||
qrBlock = `
|
qrBlock = `
|
||||||
@@ -306,7 +344,6 @@ async function openCredsModal(unit, name) {
|
|||||||
}
|
}
|
||||||
$credsBody.innerHTML = html;
|
$credsBody.innerHTML = html;
|
||||||
|
|
||||||
// Attach copy handlers
|
|
||||||
$credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => {
|
$credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const target = document.getElementById(btn.dataset.target);
|
const target = document.getElementById(btn.dataset.target);
|
||||||
@@ -332,6 +369,184 @@ function closeCredsModal() {
|
|||||||
if ($credsModal) $credsModal.classList.remove("open");
|
if ($credsModal) $credsModal.classList.remove("open");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tech Support modal ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openSupportModal() {
|
||||||
|
if (!$supportModal) return;
|
||||||
|
$supportModal.classList.add("open");
|
||||||
|
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await apiFetch("/api/support/status");
|
||||||
|
if (status.active) {
|
||||||
|
_supportEnabledAt = status.enabled_at;
|
||||||
|
renderSupportActive();
|
||||||
|
} else {
|
||||||
|
renderSupportInactive();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportInactive() {
|
||||||
|
stopSupportTimer();
|
||||||
|
const ip = _cachedExternalIp || "loading…";
|
||||||
|
$supportBody.innerHTML = `
|
||||||
|
<div class="support-section">
|
||||||
|
<div class="support-icon-big">🛟</div>
|
||||||
|
<h3 class="support-heading">Need help from Sovran Systems?</h3>
|
||||||
|
<p class="support-desc">
|
||||||
|
This will temporarily give Sovran Systems secure SSH access to your machine
|
||||||
|
so we can diagnose and fix issues for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="support-info-box">
|
||||||
|
<div class="support-info-row">
|
||||||
|
<span class="support-info-label">Your External IP</span>
|
||||||
|
<span class="support-info-value" id="support-ext-ip">${escHtml(ip)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="support-info-hint">
|
||||||
|
Give this IP to your Sovran Systems technician when asked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="support-steps">
|
||||||
|
<p class="support-steps-title">What happens when you click Enable:</p>
|
||||||
|
<ol>
|
||||||
|
<li>A Sovran Systems SSH key is added to this machine</li>
|
||||||
|
<li>You give us your External IP shown above</li>
|
||||||
|
<li>We connect and help you remotely</li>
|
||||||
|
<li>When done, you click <strong>End Support Session</strong> to remove the key</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn support-btn-enable" id="btn-support-enable">
|
||||||
|
Enable Support Access
|
||||||
|
</button>
|
||||||
|
<p class="support-fine-print">
|
||||||
|
You can end the session at any time. The access key will be completely removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupportActive() {
|
||||||
|
const ip = _cachedExternalIp || "loading…";
|
||||||
|
$supportBody.innerHTML = `
|
||||||
|
<div class="support-section">
|
||||||
|
<div class="support-icon-big support-active-icon">🔓</div>
|
||||||
|
<h3 class="support-heading support-active-heading">Support Access is Active</h3>
|
||||||
|
<p class="support-desc">
|
||||||
|
Sovran Systems can currently connect to your machine via SSH.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="support-info-box support-active-box">
|
||||||
|
<div class="support-info-row">
|
||||||
|
<span class="support-info-label">Your External IP</span>
|
||||||
|
<span class="support-info-value">${escHtml(ip)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="support-info-row">
|
||||||
|
<span class="support-info-label">Session Duration</span>
|
||||||
|
<span class="support-info-value" id="support-timer">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="support-active-note">
|
||||||
|
When your support session is complete, click the button below to
|
||||||
|
<strong>immediately remove</strong> the access key.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="btn support-btn-disable" id="btn-support-disable">
|
||||||
|
End Support Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="support-section">
|
||||||
|
<div class="support-icon-big">${icon}</div>
|
||||||
|
<h3 class="support-heading">Support Session Ended</h3>
|
||||||
|
<p class="support-desc">${escHtml(msg)}</p>
|
||||||
|
|
||||||
|
<div class="support-verify-box">
|
||||||
|
<span class="support-verify-label">SSH Key Status:</span>
|
||||||
|
<span class="support-verify-value ${verified ? "verified-gone" : "verify-warning"}">
|
||||||
|
${verified ? "✓ Removed — No access" : "⚠ Verify by rebooting"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn support-btn-done" id="btn-support-done">Done</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function openUpdateModal() {
|
function openUpdateModal() {
|
||||||
@@ -394,140 +609,4 @@ function startUpdatePoll() {
|
|||||||
|
|
||||||
function stopUpdatePoll() {
|
function stopUpdatePoll() {
|
||||||
if (_updatePollTimer) {
|
if (_updatePollTimer) {
|
||||||
clearInterval(_updatePollTimer);
|
clearInterval(_
|
||||||
_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);
|
|
||||||
@@ -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 = [
|
{ 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 = "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 <EFBFBD><EFBFBD> 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 = [
|
{ 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://"; }
|
{ label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||||
@@ -82,6 +82,10 @@ let
|
|||||||
# ── Nostr / Relay ──────────────────────────────────────────
|
# ── Nostr / Relay ──────────────────────────────────────────
|
||||||
++ [
|
++ [
|
||||||
{ name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; }
|
{ 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 =
|
activeRole =
|
||||||
@@ -105,10 +109,7 @@ let
|
|||||||
LOG="/var/log/sovran-hub-update.log"
|
LOG="/var/log/sovran-hub-update.log"
|
||||||
STATUS="/var/log/sovran-hub-update.status"
|
STATUS="/var/log/sovran-hub-update.status"
|
||||||
|
|
||||||
# Mark as RUNNING
|
|
||||||
echo "RUNNING" > "$STATUS"
|
echo "RUNNING" > "$STATUS"
|
||||||
|
|
||||||
# Truncate the log and redirect ALL output (stdout + stderr) into it
|
|
||||||
: > "$LOG"
|
: > "$LOG"
|
||||||
exec > >(tee -a "$LOG") 2>&1
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
|
||||||
@@ -177,18 +178,14 @@ let
|
|||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
# ── Python source ─────────────────────────────────────────
|
|
||||||
install -d $out/lib/sovran-hub-web
|
install -d $out/lib/sovran-hub-web
|
||||||
cp -r sovran_systemsos_web $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
|
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
||||||
|
|
||||||
# ── Icons (SVG) ──────────────────<EFBFBD><EFBFBD>─────────────────────────
|
|
||||||
install -d $out/share/sovran-hub/icons
|
install -d $out/share/sovran-hub/icons
|
||||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||||
|
|
||||||
# ── Launcher script ────────────────────────────────────────
|
|
||||||
install -d $out/bin
|
install -d $out/bin
|
||||||
cat > $out/bin/sovran-hub-web <<LAUNCHER
|
cat > $out/bin/sovran-hub-web <<LAUNCHER
|
||||||
#!${pkgs.python3}/bin/python3
|
#!${pkgs.python3}/bin/python3
|
||||||
@@ -219,7 +216,6 @@ LAUNCHER
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = {
|
config = {
|
||||||
# ── Web server as a systemd service ────────────────────────
|
|
||||||
systemd.services.sovran-hub-web = {
|
systemd.services.sovran-hub-web = {
|
||||||
description = "Sovran_SystemsOS Hub Web Interface";
|
description = "Sovran_SystemsOS Hub Web Interface";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -234,11 +230,9 @@ in
|
|||||||
StandardError = "journal";
|
StandardError = "journal";
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Make qrencode available for QR code generation ────────
|
|
||||||
path = [ pkgs.qrencode ];
|
path = [ pkgs.qrencode ];
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── System update as a detached oneshot ─────────────────────
|
|
||||||
systemd.services.sovran-hub-update = {
|
systemd.services.sovran-hub-update = {
|
||||||
description = "Sovran_SystemsOS System Update";
|
description = "Sovran_SystemsOS System Update";
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -247,7 +241,6 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Open firewall port ─────────────────────────────────────
|
|
||||||
networking.firewall.allowedTCPPorts = [ 8937 ];
|
networking.firewall.allowedTCPPorts = [ 8937 ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user