Fixes from real fresh-install feedback (Framework node .81) + its log bundle:
Backend:
- websocket: subscribe before initial snapshot — broadcasts in the gap were
silently lost, stranding clients on stale state until a hard refresh
(the "everything needs ctrl-r" bug: My Apps stuck Loading, App Store
stuck Checking, containers-scanned never arriving)
- crash recovery: check the crash marker BEFORE writing our own PID —
recovery had never run on any node (always saw its own PID and skipped);
PID-reuse guard via /proc cmdline
- boot status: pending-boot-starts registry (recovery, stack recovery,
reconciler, adoption) — scanner overlays queued-but-down apps as
Restarting instead of Stopped after a reboot; scanner-authored
Restarting resolves immediately on a settled scan (no transitional wedge)
- install deps: bounded wait (36x5s) when a dependency is installed but
still starting ("Waiting for Bitcoin to start…") instead of instant
rejection; dependency-gate rejections remove the optimistic entry (no
phantom Stopped tile) and surface as a notification
- seed backup: auth.setup persists the onboarding mnemonic as the
encrypted seed backup (reveal previously failed on EVERY node — nothing
ever wrote master_seed.enc); seed.restore stashes too; error sanitizer
lets seed/2FA errors through instead of "Check server logs"
- lnd: bitcoind.rpchost resolved from the running Bitcoin variant
(hardcoded bitcoin-knots broke Core nodes); manifest uses derived_env
- bitcoin status: clean human message for connection-reset/startup; raw
URLs + os-error chains no longer reach the app card
- fedimint-clientd: chown /var/lib/archipelago/fmcd to 1000:1000 (root-
created dir crash-looped the rootless container, EACCES) — first-boot
script + pre-start self-heal
- log volume (>1GB/day on a day-old node): journald caps drop-in (ISO +
bootstrap self-heal), bitcoind -printtoconsole=0 everywhere (90% of the
journal was IBD UpdateTip spam), tracing default debug→info
Frontend:
- Login: Enter advances to confirm field then submits; submit always
clickable with inline errors (was silently disabled on mismatch);
Restart Onboarding needs a confirming second click (the mismatch →
"onboarding restarted" trap)
- sync store: 30s state reconciliation + refetch on re-entrant connect;
20s containers-scanned escape hatch so Checking can never show forever;
fresh empty node reaches the real "no apps yet" state
- intro video: CRF20 re-encode (SSIM 0.988) + faststart — moov was at EOF
so playback needed the full 15MB first (the intro lag)
- backgrounds: 10 heaviest JPEGs → WebP q90 (9.4MB→6.6MB); 7 stayed JPEG
(WebP larger on noisy sources)
- Web5ConnectedNodes: drop unused template ref that failed vue-tsc -b
ISO/kiosk:
- nginx: /assets/ 404s no longer cached immutable for a year; HTTPS block
gained the missing /assets/ location (served index.html as images)
- kiosk: launcher/service spliced from configs/ at ISO build (stale
heredoc force-disabled GPU); MemoryHigh/Max 1200/1500→2200/2800M (kiosk
rode the reclaim throttle = the lag); firmware-intel-graphics +
firmware-amd-graphics (trixie split DMC blobs out of misc-nonfree)
Verified: cargo test 898/898 green, npm run build green with dist
contents confirmed (webp refs, lnd.png, faststart video, new strings).
Handover for ISO build + deploy: docs/HANDOVER-2026-07-02-iso-feedback.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
8.2 KiB
Rust
203 lines
8.2 KiB
Rust
use super::{RpcHandler, DEV_DEFAULT_PASSWORD};
|
|
use anyhow::Result;
|
|
|
|
impl RpcHandler {
|
|
pub(super) async fn handle_auth_login(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let password = params
|
|
.get("password")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
|
|
|
let is_setup = self.auth_manager.is_setup().await?;
|
|
if !is_setup {
|
|
// Dev mode: allow default password so UI can log in without running setup
|
|
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
|
tracing::info!("[onboarding] login via dev default password");
|
|
return Ok(serde_json::Value::Null);
|
|
}
|
|
tracing::warn!("[onboarding] login attempt before setup complete");
|
|
return Err(anyhow::anyhow!(
|
|
"User not set up. Please complete setup first."
|
|
));
|
|
}
|
|
|
|
let valid = self.auth_manager.verify_password(password).await?;
|
|
if !valid {
|
|
tracing::warn!("[onboarding] login failed — wrong password");
|
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
|
}
|
|
|
|
tracing::info!("[onboarding] login successful");
|
|
|
|
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
|
// password by rotating it onto the per-node secret, using the password
|
|
// the user just authenticated with as a candidate. Non-blocking so login
|
|
// is never slowed or broken when LND isn't installed / already unlocked.
|
|
let candidate = password.to_string();
|
|
tokio::spawn(async move {
|
|
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
|
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
|
Ok(false) => {} // not locked, or seed-recovery required
|
|
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
|
}
|
|
});
|
|
|
|
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
|
// was never called (e.g., user took the "already set up" shortcut).
|
|
let data_dir = self.config.data_dir.clone();
|
|
tokio::spawn(async move {
|
|
// Quick check: if config.toml already exists, skip
|
|
let config_path = data_dir.join("nostr-vpn/.config/nvpn/config.toml");
|
|
if config_path.exists() {
|
|
return;
|
|
}
|
|
// Identity must exist for VPN config
|
|
if !data_dir.join("identity/nostr_pubkey").exists() {
|
|
return;
|
|
}
|
|
match crate::vpn::configure_nostr_vpn(&data_dir).await {
|
|
Ok(()) => tracing::info!("[login] NostrVPN auto-configured on first login"),
|
|
Err(e) => tracing::debug!("[login] NostrVPN auto-config skipped: {}", e),
|
|
}
|
|
});
|
|
|
|
Ok(serde_json::Value::Null)
|
|
}
|
|
|
|
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
|
tracing::info!("[onboarding] logout");
|
|
Ok(serde_json::Value::Null)
|
|
}
|
|
|
|
pub(super) async fn handle_auth_change_password(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
session_token: &Option<String>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let current_password = params
|
|
.get("currentPassword")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?;
|
|
let new_password = params
|
|
.get("newPassword")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?;
|
|
let also_change_ssh = params
|
|
.get("alsoChangeSsh")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(true);
|
|
|
|
let outcome = self
|
|
.auth_manager
|
|
.change_password(current_password, new_password, also_change_ssh)
|
|
.await?;
|
|
|
|
// Session rotation: invalidate all other sessions, rotate the caller's session
|
|
if let Some(token) = session_token {
|
|
self.session_store.invalidate_all_except(token).await;
|
|
}
|
|
|
|
Ok(serde_json::json!({
|
|
"success": true,
|
|
"session_rotated": true,
|
|
"ssh_updated": outcome.ssh_updated,
|
|
"ssh_error": outcome.ssh_error,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
|
let is_setup = self.auth_manager.is_setup().await?;
|
|
Ok(serde_json::json!(is_setup))
|
|
}
|
|
|
|
pub(super) async fn handle_auth_setup(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
// Prevent re-setup if already set up
|
|
let is_setup = self.auth_manager.is_setup().await?;
|
|
if is_setup {
|
|
tracing::warn!("[onboarding] setup rejected — already set up");
|
|
return Err(anyhow::anyhow!(
|
|
"Already set up. Use auth.changePassword to change."
|
|
));
|
|
}
|
|
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let password = params
|
|
.get("password")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
|
|
|
if password.len() < 8 {
|
|
tracing::warn!("[onboarding] setup rejected — password too short");
|
|
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
|
|
}
|
|
|
|
self.auth_manager.setup_user(password).await?;
|
|
tracing::info!("[onboarding] user setup complete");
|
|
|
|
// Persist the pending onboarding seed as the encrypted backup now that
|
|
// a passphrase (the login password) finally exists — otherwise "Reveal
|
|
// recovery phrase" has nothing to decrypt on this node, ever.
|
|
// Best-effort: a failure here must not break password setup.
|
|
match super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await {
|
|
Ok(true) => tracing::info!("[onboarding] encrypted seed backup saved"),
|
|
Ok(false) => tracing::info!(
|
|
"[onboarding] no pending mnemonic to back up (restored earlier or legacy node)"
|
|
),
|
|
Err(e) => tracing::warn!("[onboarding] encrypted seed backup failed: {e:#}"),
|
|
}
|
|
|
|
Ok(serde_json::json!(true))
|
|
}
|
|
|
|
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
|
self.auth_manager.complete_onboarding().await?;
|
|
tracing::info!("[onboarding] onboarding marked complete");
|
|
|
|
// Auto-configure NostrVPN with the node's Nostr identity
|
|
let data_dir = self.config.data_dir.clone();
|
|
tokio::spawn(async move {
|
|
match crate::vpn::configure_nostr_vpn(&data_dir).await {
|
|
Ok(()) => tracing::info!("[onboarding] NostrVPN configured and started"),
|
|
Err(e) => tracing::warn!("[onboarding] NostrVPN setup (non-fatal): {}", e),
|
|
}
|
|
});
|
|
|
|
Ok(serde_json::json!(true))
|
|
}
|
|
|
|
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
|
let complete = self.auth_manager.is_onboarding_complete().await?;
|
|
tracing::debug!("[onboarding] isOnboardingComplete={}", complete);
|
|
Ok(serde_json::json!(complete))
|
|
}
|
|
|
|
pub(super) async fn handle_auth_reset_onboarding(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let password = params
|
|
.get("password")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
|
|
|
let valid = self.auth_manager.verify_password(password).await?;
|
|
if !valid {
|
|
tracing::warn!("[onboarding] reset rejected — wrong password");
|
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
|
}
|
|
|
|
self.auth_manager.reset_onboarding().await?;
|
|
tracing::info!("[onboarding] onboarding reset");
|
|
Ok(serde_json::json!(true))
|
|
}
|
|
}
|