diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index e3306c2..ea58c03 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -84,6 +84,7 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" # ── Hub web authentication ──────────────────────────────────────── FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" +MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass" HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret" SESSION_COOKIE_NAME = "hub_session" SESSION_MAX_AGE = 86400 # 24 hours @@ -574,6 +575,18 @@ def _check_password(submitted: str) -> bool: return hmac.compare_digest(submitted.encode(), stored.encode()) +def _ensure_onboarding_reopened_for_migration() -> None: + """Re-open onboarding when a migration password disclosure is pending.""" + if not os.path.isfile(MIGRATION_NEWPASS_FILE): + return + try: + os.remove(ONBOARDING_FLAG) + except FileNotFoundError: + pass + except OSError as exc: + logger.warning("Could not clear onboarding flag for migration flow: %s", exc) + + def _record_failure(client_ip: str) -> None: """Record a failed login attempt and apply a rate-limit delay. @@ -1942,6 +1955,7 @@ async def index(request: Request): @app.get("/onboarding", response_class=HTMLResponse) async def onboarding(request: Request): + _ensure_onboarding_reopened_for_migration() return templates.TemplateResponse("onboarding.html", { "request": request, "onboarding_js_hash": _ONBOARDING_JS_HASH, @@ -1950,6 +1964,7 @@ async def onboarding(request: Request): @app.get("/api/onboarding/status") async def api_onboarding_status(): + _ensure_onboarding_reopened_for_migration() complete = os.path.exists(ONBOARDING_FLAG) return {"complete": complete} @@ -1986,6 +2001,30 @@ async def api_onboarding_complete(): return {"ok": True} +@app.get("/api/migration/password-status") +async def api_migration_password_status(): + """Return whether a migration-generated password is awaiting acknowledgement.""" + try: + with open(MIGRATION_NEWPASS_FILE, "r") as f: + return {"pending": True, "password": f.read().strip()} + except FileNotFoundError: + return {"pending": False} + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}") + + +@app.post("/api/migration/password-acknowledge") +async def api_migration_password_acknowledge(): + """Acknowledge and clear the migration password disclosure marker.""" + try: + os.remove(MIGRATION_NEWPASS_FILE) + except FileNotFoundError: + pass + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not clear migration password: {exc}") + return {"ok": True} + + # ── Auto-launch endpoints ───────────────────────────────────────── @app.get("/api/autolaunch/status") diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index b6bd2f4..551c4ea 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -33,6 +33,8 @@ const DOMAIN_DEFS = [ var _currentStep = 1; var _servicesData = null; var _domainsData = null; +var _migrationPending = false; +var _migrationOccurred = false; // ── Helpers ─────────────────────────────────────────────────────── @@ -65,6 +67,48 @@ function setStatus(elId, msg, type) { el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : ""); } +function updateStep5Checklist() { + var checklist = document.getElementById("onboarding-checklist"); + if (!checklist) return; + var existing = document.getElementById("onboarding-migration-check"); + if (_migrationOccurred) { + if (!existing) { + var li = document.createElement("li"); + li.id = "onboarding-migration-check"; + li.textContent = "✅ Migration password noted"; + checklist.appendChild(li); + } + return; + } + if (existing) existing.remove(); +} + +function showMigrationStep(password) { + for (var i = 1; i <= TOTAL_STEPS; i++) { + var panel = document.getElementById("step-" + i); + if (panel) panel.style.display = "none"; + } + var migrationPanel = document.getElementById("step-migration"); + if (migrationPanel) migrationPanel.style.display = ""; + var pw = document.getElementById("migration-password-value"); + if (pw) pw.textContent = password || ""; + var progressBar = document.getElementById("onboarding-progress-bar"); + if (progressBar) progressBar.style.display = "none"; + var nav = document.getElementById("onboarding-steps-nav"); + if (nav) nav.style.display = "none"; +} + +function showStep1FromMigration() { + var migrationPanel = document.getElementById("step-migration"); + if (migrationPanel) migrationPanel.style.display = "none"; + var progressBar = document.getElementById("onboarding-progress-bar"); + if (progressBar) progressBar.style.display = ""; + var nav = document.getElementById("onboarding-steps-nav"); + if (nav) nav.style.display = ""; + showStep(1); + loadStep1(); +} + // ── Progress / step navigation ──────────────────────────────────── function updateProgress(step) { @@ -566,6 +610,24 @@ async function completeOnboarding() { // ── Event wiring ────────────────────────────────────────────────── function wireNavButtons() { + var migrationContinue = document.getElementById("migration-password-continue"); + if (migrationContinue) migrationContinue.addEventListener("click", async function() { + migrationContinue.disabled = true; + migrationContinue.textContent = "Continuing…"; + setStatus("migration-password-status", "Saving acknowledgement…", "info"); + try { + await apiFetch("/api/migration/password-acknowledge", { method: "POST" }); + _migrationPending = false; + _migrationOccurred = true; + updateStep5Checklist(); + showStep1FromMigration(); + } catch (err) { + setStatus("migration-password-status", "⚠ " + err.message, "error"); + migrationContinue.disabled = false; + migrationContinue.textContent = "I've written it down — Continue →"; + } + }); + // Step 1 → next var s1next = document.getElementById("step-1-next"); if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); }); @@ -627,6 +689,19 @@ document.addEventListener("DOMContentLoaded", async function() { } catch (_) {} wireNavButtons(); - updateProgress(1); + + try { + var migration = await apiFetch("/api/migration/password-status"); + if (migration && migration.pending) { + _migrationPending = true; + _migrationOccurred = true; + updateStep5Checklist(); + showMigrationStep(migration.password || ""); + return; + } + } catch (_) {} + + updateStep5Checklist(); + showStep(1); loadStep1(); }); diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index 39ac22f..685fee5 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -21,7 +21,7 @@
-
+
@@ -41,6 +41,33 @@
+ + +
@@ -170,4 +197,4 @@ - \ No newline at end of file + diff --git a/modules/credentials.nix b/modules/credentials.nix index b0d096c..6aa81eb 100644 --- a/modules/credentials.nix +++ b/modules/credentials.nix @@ -1,6 +1,46 @@ { config, pkgs, lib, ... }: let + gdm-migration-password-sync = pkgs.writeShellScript "gdm-migration-password-sync" '' + set -euo pipefail + + SECRET_DIR="/var/lib/secrets" + SECRET_FILE="$SECRET_DIR/free-password" + PENDING_FILE="$SECRET_DIR/free-password-migration-pending" + NEWPASS_FILE="$SECRET_DIR/free-password-migration-newpass" + + [ "''${PAM_USER:-}" = "free" ] || exit 0 + [ -f "$PENDING_FILE" ] || exit 0 + + ${pkgs.coreutils}/bin/mkdir -p "$SECRET_DIR" + + # Generate a diceware-style passphrase: word-word-word-N + WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \ + fern field flame flora flint frost grove haven hedge holly heron \ + jade juniper kelp larch lemon lilac linden loch lotus maple marsh \ + meadow mist mossy mount oak ocean olive petal pine pixel plum pond \ + prism quartz raven ridge river robin rocky rose rowan sage sand \ + sierra silver slate snow solar spark spruce stone storm summit \ + swift thorn tide timber torch trout vale vault vine walnut wave \ + willow wren amber aspen birch blaze bloom bluff coast copper crest \ + dune elder fjord forge glade glen glow gulf" + WORD_ARRAY=($WORDS) + COUNT=''${#WORD_ARRAY[@]} + W1=''${WORD_ARRAY[$((RANDOM % COUNT))]} + W2=''${WORD_ARRAY[$((RANDOM % COUNT))]} + W3=''${WORD_ARRAY[$((RANDOM % COUNT))]} + DIGIT=$((RANDOM % 10)) + FREE_PASS="$W1-$W2-$W3-$DIGIT" + + printf '%s\n' "$FREE_PASS" > "$SECRET_FILE" + ${pkgs.coreutils}/bin/chmod 600 "$SECRET_FILE" + printf 'free:%s\n' "$FREE_PASS" | ${pkgs.shadow}/bin/chpasswd + + printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE" + ${pkgs.coreutils}/bin/chmod 600 "$NEWPASS_FILE" + ${pkgs.coreutils}/bin/rm -f "$PENDING_FILE" + ''; + # ── Helper: change 'free' password and save it ───────────── change-free-password = pkgs.writeShellScriptBin "change-free-password" '' set -euo pipefail @@ -125,30 +165,66 @@ in path = [ pkgs.shadow pkgs.coreutils ]; script = '' SECRET_FILE="/var/lib/secrets/free-password" - if [ ! -f "$SECRET_FILE" ]; then - mkdir -p /var/lib/secrets - # Generate a diceware-style passphrase: word-word-word-N - WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \ - fern field flame flora flint frost grove haven hedge holly heron \ - jade juniper kelp larch lemon lilac linden loch lotus maple marsh \ - meadow mist mossy mount oak ocean olive petal pine pixel plum pond \ - prism quartz raven ridge river robin rocky rose rowan sage sand \ - sierra silver slate snow solar spark spruce stone storm summit \ - swift thorn tide timber torch trout vale vault vine walnut wave \ - willow wren amber aspen birch blaze bloom bluff coast copper crest \ - dune elder fjord forge glade glen glow gulf" - WORD_ARRAY=($WORDS) - COUNT=''${#WORD_ARRAY[@]} - W1=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W2=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W3=''${WORD_ARRAY[$((RANDOM % COUNT))]} - DIGIT=$((RANDOM % 10)) - FREE_PASS="$W1-$W2-$W3-$DIGIT" - echo "$FREE_PASS" > "$SECRET_FILE" - chmod 600 "$SECRET_FILE" + PENDING_FILE="/var/lib/secrets/free-password-migration-pending" + + if [ -f "$SECRET_FILE" ]; then + echo "free:$(cat "$SECRET_FILE")" | chpasswd + exit 0 fi - echo "free:$(cat "$SECRET_FILE")" | chpasswd + + SHADOW_HASH="" + while IFS=: read -r user hash _; do + if [ "$user" = "free" ]; then + SHADOW_HASH="$hash" + break + fi + done < /etc/shadow + + HAS_REAL_HASH=0 + case "$SHADOW_HASH" in + ""|"!"|"*"|"!!"|"!"*|"*"*) + HAS_REAL_HASH=0 + ;; + *) + HAS_REAL_HASH=1 + ;; + esac + + if [ "$HAS_REAL_HASH" -eq 1 ]; then + mkdir -p /var/lib/secrets + touch "$PENDING_FILE" + chmod 600 "$PENDING_FILE" + exit 0 + fi + + mkdir -p /var/lib/secrets + # Generate a diceware-style passphrase: word-word-word-N + WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \ + fern field flame flora flint frost grove haven hedge holly heron \ + jade juniper kelp larch lemon lilac linden loch lotus maple marsh \ + meadow mist mossy mount oak ocean olive petal pine pixel plum pond \ + prism quartz raven ridge river robin rocky rose rowan sage sand \ + sierra silver slate snow solar spark spruce stone storm summit \ + swift thorn tide timber torch trout vale vault vine walnut wave \ + willow wren amber aspen birch blaze bloom bluff coast copper crest \ + dune elder fjord forge glade glen glow gulf" + WORD_ARRAY=($WORDS) + COUNT=''${#WORD_ARRAY[@]} + W1=''${WORD_ARRAY[$((RANDOM % COUNT))]} + W2=''${WORD_ARRAY[$((RANDOM % COUNT))]} + W3=''${WORD_ARRAY[$((RANDOM % COUNT))]} + DIGIT=$((RANDOM % 10)) + FREE_PASS="$W1-$W2-$W3-$DIGIT" + echo "$FREE_PASS" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "free:$FREE_PASS" | chpasswd ''; }; + security.pam.services.gdm-password.text = lib.mkAfter (lib.optionalString + (config.sovran_systemsOS.roles.desktop || config.sovran_systemsOS.roles.server_plus_desktop) + '' + session optional pam_exec.so quiet ${gdm-migration-password-sync} + ''); + }