diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 8348b17..79f61f4 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -259,6 +259,20 @@ ROLE_LABELS = { "node": "Bitcoin Node", } +# Categories shown per role (None = show all) +ROLE_CATEGORIES: dict[str, set[str] | None] = { + "server_plus_desktop": None, + "desktop": {"infrastructure", "support", "feature-manager"}, + "node": {"infrastructure", "bitcoin-base", "bitcoin-apps", "support", "feature-manager"}, +} + +# Features shown per role (None = show all) +ROLE_FEATURES: dict[str, set[str] | None] = { + "server_plus_desktop": None, + "desktop": {"rdp"}, + "node": {"bip110", "bitcoin-core", "mempool"}, +} + SERVICE_DESCRIPTIONS: dict[str, str] = { "bitcoind.service": ( "The foundation of your financial sovereignty. Your node independently verifies " @@ -1322,14 +1336,69 @@ async def api_onboarding_complete(): async def api_config(): cfg = load_config() role = cfg.get("role", "server_plus_desktop") + allowed_cats = ROLE_CATEGORIES.get(role) + cats = CATEGORY_ORDER if allowed_cats is None else [ + c for c in CATEGORY_ORDER if c[0] in allowed_cats + ] return { "role": role, "role_label": ROLE_LABELS.get(role, role), - "category_order": CATEGORY_ORDER, + "category_order": cats, "feature_manager": True, } +ROLE_STATE_NIX = """\ +# THIS FILE IS AUTO-GENERATED. DO NOT EDIT. +{ config, lib, ... }: +{ + sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault true; + sovran_systemsOS.roles.desktop = lib.mkDefault false; + sovran_systemsOS.roles.node = lib.mkDefault false; +} +""" + + +@app.post("/api/role/upgrade-to-server") +async def api_upgrade_to_server(): + """Upgrade from Node role to Server+Desktop role by writing role-state.nix and rebuilding.""" + cfg = load_config() + if cfg.get("role", "server_plus_desktop") != "node": + raise HTTPException(status_code=400, detail="Upgrade is only available for the Node role.") + + try: + with open("/etc/nixos/role-state.nix", "w") as f: + f.write(ROLE_STATE_NIX) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to write role-state.nix: {exc}") + + # Reset onboarding so the wizard runs for the newly unlocked services + try: + os.remove(ONBOARDING_FLAG) + except FileNotFoundError: + pass + + # Clear stale rebuild log + try: + open(REBUILD_LOG, "w").close() + except OSError: + pass + + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "rebuilding"} + + @app.get("/api/services") async def api_services(): cfg = load_config() @@ -2082,8 +2151,14 @@ async def api_features(): ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail") ssl_email_configured = os.path.exists(ssl_email_path) + role = load_config().get("role", "server_plus_desktop") + allowed_features = ROLE_FEATURES.get(role) + registry = FEATURE_REGISTRY if allowed_features is None else [ + f for f in FEATURE_REGISTRY if f["id"] in allowed_features + ] + features = [] - for feat in FEATURE_REGISTRY: + for feat in registry: feat_id = feat["id"] # Determine enabled state: diff --git a/app/sovran_systemsos_web/static/css/layout.css b/app/sovran_systemsos_web/static/css/layout.css index 090d544..9aa1e74 100644 --- a/app/sovran_systemsos_web/static/css/layout.css +++ b/app/sovran_systemsos_web/static/css/layout.css @@ -82,6 +82,62 @@ margin: 16px 0; } +/* ── Sidebar: Upgrade button (Node role) ────────────────────────── */ + +.sidebar-upgrade-btn { + border-color: var(--accent-color); + background-color: rgba(137, 180, 250, 0.06); + margin-top: 8px; +} + +.sidebar-upgrade-btn:hover { + background-color: rgba(137, 180, 250, 0.14); + border-color: var(--accent-color); +} + +.sidebar-upgrade-btn .sidebar-support-hint { + color: var(--accent-color); +} + +/* ── Upgrade modal ──────────────────────────────────────────────── */ + +.upgrade-dialog { + max-width: 480px; +} + +.upgrade-info-box { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 14px; +} + +.upgrade-info-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.upgrade-info-list { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; + margin: 0; +} + +.upgrade-info-list a { + color: var(--accent-color); +} + +.upgrade-rebuild-note { + font-style: italic; + color: var(--text-dim); + font-size: 0.82rem; +} + /* ── Tiles area ─────────────────────────────────────────────────── */ #tiles-area { diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index 74ba7a0..2f4733b 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -33,6 +33,43 @@ if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $mod if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); +// Upgrade modal +if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeModal); +if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal); +if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); }); + +// ── Upgrade modal functions ─────────────────────────────────────── + +function openUpgradeModal() { + if ($upgradeModal) $upgradeModal.classList.add("open"); +} + +function closeUpgradeModal() { + if ($upgradeModal) $upgradeModal.classList.remove("open"); +} + +async function doUpgradeToServer() { + var confirmBtn = $upgradeConfirmBtn; + if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; } + closeUpgradeModal(); + + // Reuse the rebuild modal to show progress + _rebuildFeatureName = "Server + Desktop"; + _rebuildIsEnabling = true; + openRebuildModal(); + + try { + await apiFetch("/api/role/upgrade-to-server", { method: "POST" }); + } catch (err) { + if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message; + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = "Yes, Upgrade"; } + } +} + +if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer); + // ── Init ────────────────────────────────────────────────────────── async function init() { @@ -49,6 +86,7 @@ async function init() { try { var cfg = await apiFetch("/api/config"); + _currentRole = cfg.role || "server_plus_desktop"; if (cfg.category_order) { for (var i = 0; i < cfg.category_order.length; i++) { _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index 4da0501..ed4d588 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -15,6 +15,9 @@ let _supportStatus = null; // last fetched /api/support/status payload let _walletUnlockTimerInt = null; let _cachedExternalIp = null; +// Current role (set during init from /api/config) +let _currentRole = "server_plus_desktop"; + // Feature Manager state let _featuresData = null; let _rebuildLog = ""; @@ -89,5 +92,11 @@ const $portReqModal = document.getElementById("port-requirements-modal"); const $portReqBody = document.getElementById("port-req-body"); const $portReqClose = document.getElementById("port-req-close-btn"); +// Upgrade modal (Node → Server+Desktop) +const $upgradeModal = document.getElementById("upgrade-modal"); +const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn"); +const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn"); +const $upgradeCloseBtn = document.getElementById("upgrade-close-btn"); + // System status banner // (removed — health is now shown per-tile via the composite health field) \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 1bf7dad..1574182 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -71,6 +71,20 @@ function renderSidebarSupport(supportServices) { backupBtn.addEventListener("click", function() { openBackupModal(); }); $sidebarSupport.appendChild(backupBtn); + // ── Upgrade button (Node role only) + if (_currentRole === "node") { + var upgradeBtn = document.createElement("button"); + upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn"; + upgradeBtn.innerHTML = + '🚀' + + '' + + 'Upgrade to Full Server' + + 'Unlock all services' + + ''; + upgradeBtn.addEventListener("click", function() { openUpgradeModal(); }); + $sidebarSupport.appendChild(upgradeBtn); + } + var hr = document.createElement("hr"); hr.className = "sidebar-divider"; $sidebarSupport.appendChild(hr); diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 3b04de9..55a7499 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -6,6 +6,16 @@ const TOTAL_STEPS = 4; +// Steps to skip per role (steps 2 and 3 involve domain/port setup) +const ROLE_SKIP_STEPS = { + "desktop": [2, 3], + "node": [2, 3], +}; + +// ── Role state (loaded at init) ─────────────────────────────────── + +var _onboardingRole = "server_plus_desktop"; + // Domains that may need configuration, with service unit mapping for enabled check const DOMAIN_DEFS = [ { name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true }, @@ -83,6 +93,22 @@ function showStep(step) { if (step === 3) loadStep3(); } +// Return the next step number, skipping over role-excluded steps +function nextStep(current) { + var skip = ROLE_SKIP_STEPS[_onboardingRole] || []; + var next = current + 1; + while (next < TOTAL_STEPS && skip.indexOf(next) !== -1) next++; + return next; +} + +// Return the previous step number, skipping over role-excluded steps +function prevStep(current) { + var skip = ROLE_SKIP_STEPS[_onboardingRole] || []; + var prev = current - 1; + while (prev > 1 && skip.indexOf(prev) !== -1) prev--; + return prev; +} + // ── Step 1: Welcome ─────────────────────────────────────────────── async function loadStep1() { @@ -319,9 +345,9 @@ async function completeOnboarding() { // ── Event wiring ────────────────────────────────────────────────── function wireNavButtons() { - // Step 1 → 2 + // Step 1 → next (may skip 2+3 for desktop/node) var s1next = document.getElementById("step-1-next"); - if (s1next) s1next.addEventListener("click", function() { showStep(2); }); + if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); }); // Step 2 → 3 (save first) var s2next = document.getElementById("step-2-next"); @@ -331,12 +357,12 @@ function wireNavButtons() { await saveStep2(); s2next.disabled = false; s2next.textContent = "Save & Continue →"; - showStep(3); + showStep(nextStep(2)); }); // Step 3 → 4 (Complete) var s3next = document.getElementById("step-3-next"); - if (s3next) s3next.addEventListener("click", function() { showStep(4); }); + if (s3next) s3next.addEventListener("click", function() { showStep(nextStep(3)); }); // Step 4: finish var s4finish = document.getElementById("step-4-finish"); @@ -345,7 +371,7 @@ function wireNavButtons() { // Back buttons document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { var prev = parseInt(btn.dataset.prev, 10); - btn.addEventListener("click", function() { showStep(prev); }); + btn.addEventListener("click", function() { showStep(prevStep(prev + 1)); }); }); } @@ -361,6 +387,12 @@ document.addEventListener("DOMContentLoaded", async function() { } } catch (_) {} + // Load role so step-skipping is applied before wiring nav buttons + try { + var cfg = await apiFetch("/api/config"); + if (cfg.role) _onboardingRole = cfg.role; + } catch (_) {} + wireNavButtons(); updateProgress(1); loadStep1(); diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index f38f8d5..9f9b37c 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -172,6 +172,40 @@ + + +
diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0790928..e2f5b5b 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -4,16 +4,22 @@ let cfg = config.sovran_systemsOS; monitoredServices = - # ── Infrastructure (always present) ──────────────────────── + # ── Infrastructure — System Passwords (always present) ───── [ - { 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 = []; } { name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "passwords"; enabled = true; category = "infrastructure"; credentials = [ { label = "Free Account — Username"; value = "free"; } { label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; } { label = "Root Password"; file = "/var/lib/secrets/root-password"; } { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } ]; } + ] + # ── Infrastructure — Caddy + Tor (NOT desktop-only) ──────── + ++ lib.optionals (!cfg.roles.desktop) [ + { 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 = []; } + ] + # ── Infrastructure — Remote Desktop (roles with a desktop) ─ + ++ lib.optionals (!cfg.roles.node) [ { name = "Remote Desktop"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "infrastructure"; credentials = [ { label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; } { label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; } @@ -22,7 +28,7 @@ let ]; } ] # ── Bitcoin Base (node implementations) ──────────────────── - ++ [ + ++ lib.optionals cfg.services.bitcoin [ { 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"; prefix = "http://"; } ]; } @@ -34,7 +40,7 @@ let ]; } ] # ── Bitcoin Apps (services on top of the node) ───────────── - ++ [ + ++ lib.optionals cfg.services.bitcoin [ { 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"; prefix = "http://"; } { label = "Port"; value = "50001"; } @@ -58,8 +64,8 @@ let { label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; } ]; } ] - # ── Communication ────────────────────────────────────────── - ++ [ + # ── Communication (server+desktop only) ──────────────────── + ++ lib.optionals cfg.roles.server_plus_desktop [ { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [ { label = "Homeserver URL"; file = "/var/lib/secrets/matrix-homeserver-url"; } { label = "Admin Username"; file = "/var/lib/secrets/matrix-admin-username"; } @@ -69,8 +75,8 @@ let ]; } { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; } ] - # ── Self-Hosted Apps ─────────────────────────────────────── - ++ [ + # ── Self-Hosted Apps (server+desktop only) ───────────────── + ++ lib.optionals cfg.roles.server_plus_desktop [ { 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"; } @@ -83,11 +89,11 @@ let { label = "Credentials"; file = "/var/lib/secrets/wordpress-admin"; multiline = true; } ]; } ] - # ── Nostr / Relay ────────────────────────────────────────── - ++ [ + # ── Nostr / Relay (server+desktop only) ──────────────────── + ++ lib.optionals cfg.roles.server_plus_desktop [ { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; } ] - # ── Support ──────────────────────────────────────────────── + # ── Support (always present) ──────────────────────────────── ++ [ { name = "Tech Support"; unit = "sovran-tech-support"; type = "support"; icon = "support"; enabled = true; category = "support"; credentials = []; } ];