diff --git a/core/Cargo.lock b/core/Cargo.lock index 7fa21590..5a963966 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -84,7 +84,6 @@ version = "1.2.0-alpha" dependencies = [ "anyhow", "archipelago-container", - "archipelago-parmanode", "archipelago-performance", "archipelago-security", "argon2", @@ -160,20 +159,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "archipelago-parmanode" -version = "0.1.0" -dependencies = [ - "anyhow", - "archipelago-container", - "log", - "serde", - "serde_yaml", - "thiserror 1.0.69", - "tokio", - "tracing", -] - [[package]] name = "archipelago-performance" version = "0.1.0" diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index 4f1a765c..9482cadd 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -16,8 +16,10 @@ impl RpcHandler { 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." )); @@ -25,13 +27,16 @@ impl RpcHandler { 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"); Ok(serde_json::Value::Null) } pub(super) async fn handle_auth_logout(&self) -> Result { + tracing::info!("[onboarding] logout"); Ok(serde_json::Value::Null) } @@ -78,6 +83,7 @@ impl RpcHandler { // 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.")); } @@ -88,20 +94,24 @@ impl RpcHandler { .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"); Ok(serde_json::json!(true)) } pub(super) async fn handle_auth_onboarding_complete(&self) -> Result { self.auth_manager.complete_onboarding().await?; + tracing::info!("[onboarding] onboarding marked complete"); Ok(serde_json::json!(true)) } pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result { let complete = self.auth_manager.is_onboarding_complete().await?; + tracing::debug!("[onboarding] isOnboardingComplete={}", complete); Ok(serde_json::json!(complete)) } @@ -117,10 +127,12 @@ impl RpcHandler { 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)) } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 13f58059..e658ff62 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -153,9 +153,11 @@ impl RpcHandler { } if let Some(proto) = headers.get("x-forwarded-proto") { if proto.as_bytes() == b"https" { + tracing::debug!("[onboarding] cookie: Secure (X-Forwarded-Proto: https)"); return "; Secure"; } } + tracing::debug!("[onboarding] cookie: no Secure flag (HTTP or no X-Forwarded-Proto)"); "" } diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index dc2f1c17..d10ad526 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -228,6 +228,11 @@ impl RpcHandler { if !run_output.status.success() { let stderr = String::from_utf8_lossy(&run_output.stderr); + // Rollback: remove partially created container + let _ = tokio::process::Command::new("podman") + .args(["rm", "-f", container_name]) + .output() + .await; return Err(anyhow::anyhow!("Failed to start container: {}", stderr)); } @@ -235,6 +240,43 @@ impl RpcHandler { .trim() .to_string(); + // Post-start health verification: wait up to 30s for container to be running + for i in 0..6u32 { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let status = tokio::process::Command::new("podman") + .args(["inspect", container_name, "--format", "{{.State.Status}}"]) + .output() + .await; + if let Ok(o) = status { + let state = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if state == "running" { + break; + } + if state == "exited" { + // Container crashed immediately — get logs for diagnosis + let logs = tokio::process::Command::new("podman") + .args(["logs", "--tail", "20", container_name]) + .output() + .await; + let log_output = logs + .map(|o| String::from_utf8_lossy(&o.stderr).to_string()) + .unwrap_or_default(); + let _ = tokio::process::Command::new("podman") + .args(["rm", "-f", container_name]) + .output() + .await; + return Err(anyhow::anyhow!( + "Container {} exited immediately after start. Logs: {}", + container_name, + log_output.chars().take(500).collect::() + )); + } + } + if i == 5 { + debug!("Container {} health check timeout (30s) — continuing anyway", container_name); + } + } + // Post-install hooks self.run_post_install_hooks(package_id).await; @@ -301,11 +343,43 @@ impl RpcHandler { Ok(has_local_fallback) } - /// Stream `podman pull` while updating install progress state. + /// Pull image with retry and exponential backoff (3 attempts: 5s, 15s, 45s). async fn pull_image_with_progress( &self, package_id: &str, docker_image: &str, + ) -> Result<()> { + const MAX_ATTEMPTS: u32 = 3; + const BACKOFF_SECS: [u64; 3] = [5, 15, 45]; + + for attempt in 1..=MAX_ATTEMPTS { + match self.do_pull_image(package_id, docker_image).await { + Ok(()) => return Ok(()), + Err(e) if attempt < MAX_ATTEMPTS => { + let delay = BACKOFF_SECS[(attempt - 1) as usize]; + tracing::warn!( + "Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...", + docker_image, attempt, MAX_ATTEMPTS, e, delay + ); + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + } + Err(e) => { + self.clear_install_progress(package_id).await; + return Err(e.context(format!( + "Failed to pull {} after {} attempts", + docker_image, MAX_ATTEMPTS + ))); + } + } + } + unreachable!() + } + + /// Single image pull attempt with progress streaming. + async fn do_pull_image( + &self, + package_id: &str, + docker_image: &str, ) -> Result<()> { debug!("Pulling image: {}", docker_image); self.set_install_progress(package_id, 0, 0).await; @@ -336,8 +410,20 @@ impl RpcHandler { .await .context("Failed to wait for image pull")?; if !status.success() { - self.clear_install_progress(package_id).await; - return Err(anyhow::anyhow!("Failed to pull image")); + return Err(anyhow::anyhow!("podman pull exited with non-zero status")); + } + + // Verify image exists locally after pull + let verify = tokio::process::Command::new("podman") + .args(["images", "-q", docker_image]) + .output() + .await + .context("Failed to verify pulled image")?; + if String::from_utf8_lossy(&verify.stdout).trim().is_empty() { + return Err(anyhow::anyhow!( + "Image {} not found locally after pull", + docker_image + )); } self.set_install_progress(package_id, 100, 100).await; diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 5e692d06..ae136f7d 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -4,6 +4,22 @@ use super::validation::validate_app_id; use crate::api::rpc::RpcHandler; use anyhow::{Context, Result}; +/// Per-container graceful shutdown timeout in seconds. +/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state, +/// indexers 300s for index flush, databases 120s for WAL/transaction commit. +fn stop_timeout_secs(container_name: &str) -> &'static str { + let id = container_name.strip_prefix("archy-").unwrap_or(container_name); + match id { + "bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600", + "lnd" => "330", + "electrumx" | "electrs" | "mempool-electrs" => "300", + "btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres" + | "nextcloud-db" | "endurain-db" => "120", + "btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60", + _ => "30", + } +} + impl RpcHandler { /// Start a package: start all containers in dependency order. pub(in crate::api::rpc) async fn handle_package_start( @@ -56,7 +72,7 @@ impl RpcHandler { crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name) .await; let _ = tokio::process::Command::new("podman") - .args(["stop", &container_name]) + .args(["stop", "-t", stop_timeout_secs(&container_name), &container_name]) .output() .await; return Ok(serde_json::Value::Null); @@ -67,7 +83,7 @@ impl RpcHandler { } for name in containers { let _ = tokio::process::Command::new("podman") - .args(["stop", &name]) + .args(["stop", "-t", stop_timeout_secs(&name), &name]) .output() .await; } @@ -135,7 +151,7 @@ impl RpcHandler { for name in &containers_to_remove { tracing::info!("Uninstall {}: stopping container {}", package_id, name); let stop_out = tokio::process::Command::new("podman") - .args(["stop", "-t", "10", name]) + .args(["stop", "-t", stop_timeout_secs(name), name]) .output() .await; match stop_out { @@ -344,7 +360,7 @@ impl RpcHandler { validate_app_id(app_id)?; let output = tokio::process::Command::new("podman") - .args(["stop", app_id]) + .args(["stop", "-t", stop_timeout_secs(app_id), app_id]) .output() .await .context("Failed to stop container")?; diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index 9e5b881d..03250c1e 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -7,6 +7,41 @@ use crate::api::rpc::RpcHandler; use anyhow::{Context, Result}; use tracing::info; +/// Pull an image with retry and exponential backoff (3 attempts). +async fn pull_image_with_retry(image: &str) -> Result<()> { + const MAX_ATTEMPTS: u32 = 3; + const BACKOFF_SECS: [u64; 3] = [5, 15, 45]; + + for attempt in 1..=MAX_ATTEMPTS { + let output = tokio::process::Command::new("podman") + .args(["pull", image]) + .output() + .await + .context("Failed to execute podman pull")?; + + if output.status.success() { + return Ok(()); + } + + if attempt < MAX_ATTEMPTS { + let delay = BACKOFF_SECS[(attempt - 1) as usize]; + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...", + image, attempt, MAX_ATTEMPTS, stderr.trim(), delay + ); + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to pull {} after {} attempts: {}", + image, MAX_ATTEMPTS, stderr.trim() + )); + } + } + unreachable!() +} + impl RpcHandler { /// Install Immich stack (postgres + redis + server). pub(super) async fn install_immich_stack(&self) -> Result { @@ -38,10 +73,7 @@ impl RpcHandler { "80.71.235.15:3000/archipelago/immich-server:release", ]; for img in &images { - let _ = tokio::process::Command::new("podman") - .args(["pull", img]) - .output() - .await; + pull_image_with_retry(img).await?; } let _ = tokio::process::Command::new("sudo") @@ -168,10 +200,7 @@ impl RpcHandler { "80.71.235.15:3000/archipelago/penpot-frontend:2.4", ]; for img in &images { - let _ = tokio::process::Command::new("podman") - .args(["pull", img]) - .output() - .await; + pull_image_with_retry(img).await?; } let _ = tokio::process::Command::new("sudo") diff --git a/core/archipelago/src/crash_recovery.rs b/core/archipelago/src/crash_recovery.rs index 41a37c5f..2bcb49cb 100644 --- a/core/archipelago/src/crash_recovery.rs +++ b/core/archipelago/src/crash_recovery.rs @@ -384,6 +384,36 @@ fn container_boot_tier(name: &str) -> u8 { } } +/// Run the reconciliation script after boot to fix any config drift. +/// Ensures all containers match their canonical specs from container-specs.sh. +pub async fn run_boot_reconciliation() { + let script = "/home/archipelago/archy/scripts/reconcile-containers.sh"; + if !std::path::Path::new(script).exists() { + info!("Reconciliation script not found (dev mode?) — skipping boot reconciliation"); + return; + } + info!("Running boot reconciliation..."); + let result = tokio::time::timeout( + std::time::Duration::from_secs(300), + tokio::process::Command::new(script).output(), + ) + .await; + match result { + Ok(Ok(output)) if output.status.success() => { + info!("Boot reconciliation complete"); + } + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!( + "Boot reconciliation had failures: {}", + stderr.chars().take(500).collect::() + ); + } + Ok(Err(e)) => warn!("Boot reconciliation failed to run: {}", e), + Err(_) => warn!("Boot reconciliation timed out (300s)"), + } +} + /// Spawn a background task that periodically saves the container snapshot. pub fn spawn_snapshot_task(data_dir: PathBuf) { tokio::spawn(async move { diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 2350dd11..6897663c 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -6,8 +6,9 @@ use crate::data_model::{Notification, NotificationLevel}; use crate::state::StateManager; use crate::webhooks::{self, WebhookEvent}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; use tracing::{debug, info, warn}; @@ -177,6 +178,69 @@ impl MemoryTracker { } +// ── Persistent restart tracking ──────────────────────────────────────── +// Survives process restarts so a container can't loop infinitely by +// crashing 3 times → triggering process restart → resetting counter → repeat. + +const RESTART_HISTORY_FILE: &str = "restart-tracker.json"; + +#[derive(Serialize, Deserialize, Default)] +struct RestartHistory { + containers: HashMap, +} + +#[derive(Serialize, Deserialize, Clone)] +struct ContainerRestartRecord { + attempts: u32, + last_failure_epoch: i64, +} + +impl RestartHistory { + async fn load(data_dir: &Path) -> Self { + let path = data_dir.join(RESTART_HISTORY_FILE); + match tokio::fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + async fn save(&self, data_dir: &Path) { + let path = data_dir.join(RESTART_HISTORY_FILE); + if let Ok(json) = serde_json::to_string(self) { + let _ = tokio::fs::write(&path, json).await; + } + } + + /// Seed the in-memory RestartTracker from persisted history. + fn seed_tracker(&self, tracker: &mut RestartTracker) { + let now_epoch = chrono::Utc::now().timestamp(); + for (name, record) in &self.containers { + // Only seed if last failure was within the stability window + let secs_since_failure = now_epoch - record.last_failure_epoch; + if secs_since_failure < STABILITY_RESET_SECS as i64 && record.attempts > 0 { + tracker.attempts.insert(name.clone(), record.attempts); + info!( + "Restored restart counter for {}: {} attempts ({}s ago)", + name, record.attempts, secs_since_failure + ); + } + } + } + + fn record_attempt(&mut self, name: &str) { + let entry = self.containers.entry(name.to_string()).or_insert(ContainerRestartRecord { + attempts: 0, + last_failure_epoch: 0, + }); + entry.attempts += 1; + entry.last_failure_epoch = chrono::Utc::now().timestamp(); + } + + fn clear(&mut self, name: &str) { + self.containers.remove(name); + } +} + /// Query container memory stats from podman. async fn check_container_memory() -> HashMap { let output = match tokio::time::timeout( @@ -373,6 +437,11 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { let mut mem_check_counter: u32 = 0; let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)); + // Load persistent restart history and seed the in-memory tracker + let mut restart_history = RestartHistory::load(&data_dir).await; + restart_history.seed_tracker(&mut tracker); + let mut history_dirty = false; + loop { interval.tick().await; mem_check_counter += 1; @@ -406,6 +475,8 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { if tracker.attempt_count(&container.name) > 0 { info!("Container {} is healthy again after restart", container.name); tracker.clear(&container.name); + restart_history.clear(&container.name); + history_dirty = true; } continue; } @@ -430,6 +501,8 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { if tracker.should_reset_failed(&container.name) { info!("Resetting restart counter for {} after {}s stability window", container.name, STABILITY_RESET_SECS); tracker.clear(&container.name); + restart_history.clear(&container.name); + history_dirty = true; } if tracker.attempt_count(&container.name) >= MAX_RESTART_ATTEMPTS { @@ -453,6 +526,8 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { prev_tier = Some(tier); if tracker.record_attempt(&container.name) { + restart_history.record_attempt(&container.name); + history_dirty = true; let attempt = tracker.attempt_count(&container.name); info!("Restarting {} (tier {:?}, attempt {}/{}, backoff {}s)", container.name, tier, attempt, MAX_RESTART_ATTEMPTS, @@ -509,6 +584,12 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { state.update_data(data).await; debug!("Health monitor: state updated with notifications"); } + + // Persist restart history to disk (debounced: once per check cycle) + if history_dirty { + restart_history.save(&data_dir).await; + history_dirty = false; + } } }); } diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index d924c4b9..bacf1638 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -106,6 +106,9 @@ async fn main() -> Result<()> { // Signal to health monitor that boot recovery is done crash_recovery::mark_recovery_complete(); + + // Reconcile containers against canonical specs (fixes config drift) + crash_recovery::run_boot_reconciliation().await; }); } diff --git a/core/container/src/health_monitor.rs b/core/container/src/health_monitor.rs index 10de86ae..88a431cd 100644 --- a/core/container/src/health_monitor.rs +++ b/core/container/src/health_monitor.rs @@ -135,9 +135,14 @@ impl HealthMonitor { HealthStatus::Unhealthy => { consecutive_failures += 1; if consecutive_failures >= max_failures { - error!("Container {} is unhealthy after {} failures", + error!("Container {} is unhealthy after {} failures", self.container_name, consecutive_failures); - // TODO: Trigger auto-restart or alert + // Auto-restart is handled by the orchestrator-level health monitor + // (core/archipelago/src/health_monitor.rs) which runs every 60s, + // checks all container states via `podman ps`, and restarts + // exited containers with exponential backoff (10s/30s/90s). + // This per-container monitor is for manifest-driven health + // tracking and status change callbacks only. } } _ => {} diff --git a/image-recipe/archipelago-scripts/archipelago-menu.sh b/image-recipe/archipelago-scripts/archipelago-menu.sh index c7f27549..0b7bab1a 100755 --- a/image-recipe/archipelago-scripts/archipelago-menu.sh +++ b/image-recipe/archipelago-scripts/archipelago-menu.sh @@ -1,18 +1,39 @@ #!/bin/bash # -# Archipelago Main Menu -# Interactive setup wizard for Archipelago Bitcoin Node OS +# archipelago main menu +# interactive setup for archipelago bitcoin node os # SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Colors (256-color — works on Linux console with fbcon) +O=$'\033[38;5;208m' # Orange +W=$'\033[1;37m' # Bold white +D=$'\033[38;5;242m' # Dim +C=$'\033[38;5;37m' # Cyan +G=$'\033[38;5;35m' # Green +R=$'\033[38;5;196m' # Red +Y=$'\033[38;5;220m' # Yellow +N=$'\033[0m' # Reset + +# Adaptive centering +get_width() { TW=$(tput cols 2>/dev/null || echo 60); [ "$TW" -gt 120 ] && TW=120; } +get_width +cc() { local s=$(echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g'); local p=$(( (TW - ${#s}) / 2 )); [ $p -lt 0 ] && p=0; printf "%*s" "$p" ""; echo -e "$1"; } + +# Box helpers (Claude-style rounded corners) +bw() { echo $((TW > 52 ? 52 : TW - 4)); } +btop() { local w=$(bw); local t="╭"; for i in $(seq 1 $((w-2))); do t="${t}─"; done; cc "${D}${t}╮${N}"; } +bbox() { local w=$(bw); local s=$(echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g'); local pad=$((w - 2 - ${#s})); [ $pad -lt 0 ] && pad=0; local r=""; for i in $(seq 1 $pad); do r="${r} "; done; cc "${D}│${N} $1${r}${D}│${N}"; } +bbot() { local w=$(bw); local b="╰"; for i in $(seq 1 $((w-2))); do b="${b}─"; done; cc "${D}${b}╯${N}"; } +hrule() { local len=$((TW > 50 ? 50 : TW - 4)); local hr=""; for i in $(seq 1 $len); do hr="${hr}─"; done; cc "${D}${hr}${N}"; } + # Install required tools on first run (for live mode) install_required_tools() { if [ -f /tmp/.archipelago-tools-installed ]; then return 0 fi - - # Check if we need to install tools + local NEED_TOOLS=0 for tool in parted debootstrap mkfs.ext4 mkfs.vfat; do if ! command -v $tool >/dev/null 2>&1; then @@ -20,74 +41,61 @@ install_required_tools() { break fi done - + if [ $NEED_TOOLS -eq 1 ]; then echo "" - echo " 📦 Installing required tools (first run)..." + cc "${D}installing required tools...${N}" echo "" sudo apt-get update -qq 2>/dev/null sudo apt-get install -y parted debootstrap dosfstools e2fsprogs 2>/dev/null - echo " ✅ Tools installed" + cc "${G}tools installed${N}" echo "" sleep 1 fi - + touch /tmp/.archipelago-tools-installed } -# Run tool installation at startup install_required_tools show_banner() { + get_width clear echo "" - echo " ╔═══════════════════════════════════════════════════════════╗" - echo " ║ ║" - echo " ║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║" - echo " ║ ║" - echo " ║ Your sovereign Bitcoin infrastructure ║" - echo " ║ ║" - echo " ╚═══════════════════════════════════════════════════════════╝" + btop + bbox "" + bbox "${W}a r c h i p e l a g o${N}" + bbox "${O}━━━━━━━━━━━━━━━━━━━━━${N}" + bbox "${D}bitcoin node os${N}" + bbox "" + bbot echo "" } show_status() { - echo " System Status:" - echo " ─────────────────────────────────────────────────────────────" - - # Check if we're in live mode if [ -d /run/live ]; then - echo " Mode: 🔴 Live (changes won't persist)" + cc "${R}live mode${N} ${D}(changes won't persist)${N}" else - echo " Mode: 🟢 Installed" + cc "${G}installed${N}" fi - - # Check Podman - if command -v podman >/dev/null 2>&1; then - echo " Podman: 🟢 Installed" - else - echo " Podman: 🔴 Not installed" + echo "" + + local podman_ok=0 + command -v podman >/dev/null 2>&1 && podman_ok=1 + + if [ $podman_ok -eq 1 ] && podman ps 2>/dev/null | grep -q bitcoind; then + local blocks=$(podman exec bitcoind bitcoin-cli getblockcount 2>/dev/null || echo "syncing") + cc "${G}bitcoin${N} ${D}running ($blocks blocks)${N}" + elif [ $podman_ok -eq 1 ] && podman ps -a 2>/dev/null | grep -q bitcoind; then + cc "${Y}bitcoin${N} ${D}stopped${N}" fi - - # Check Bitcoin Core - if podman ps 2>/dev/null | grep -q bitcoind; then - BLOCKS=$(podman exec bitcoind bitcoin-cli getblockcount 2>/dev/null || echo "syncing") - echo " Bitcoin: 🟢 Running (blocks: $BLOCKS)" - elif podman ps -a 2>/dev/null | grep -q bitcoind; then - echo " Bitcoin: 🟡 Stopped" - else - echo " Bitcoin: ⚪ Not configured" + + if [ $podman_ok -eq 1 ] && podman ps 2>/dev/null | grep -q lnd; then + cc "${G}lightning${N} ${D}running${N}" + elif [ $podman_ok -eq 1 ] && podman ps -a 2>/dev/null | grep -q lnd; then + cc "${Y}lightning${N} ${D}stopped${N}" fi - - # Check LND - if podman ps 2>/dev/null | grep -q lnd; then - echo " Lightning: 🟢 Running" - elif podman ps -a 2>/dev/null | grep -q lnd; then - echo " Lightning: 🟡 Stopped" - else - echo " Lightning: ⚪ Not configured" - fi - + echo "" } @@ -95,126 +103,112 @@ main_menu() { while true; do show_banner show_status - - # Show Web UI URL prominently + + # Connection info IP=$(hostname -I 2>/dev/null | awk '{print $1}') [ -z "$IP" ] && IP=$(ip -4 addr show | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127.0.0.1' | head -1) - - echo " ┌─────────────────────────────────────────────────────────────┐" + if [ -n "$IP" ]; then - # Check if backend is running if pgrep -f "archipelago" >/dev/null 2>&1; then - echo " │ 🌐 Web UI: http://$IP:5678 (running) │" + cc "${C}web ui${N} ${W}http://$IP${N}" else - echo " │ 🌐 Web UI: http://$IP:5678 (not started) │" + cc "${C}web ui${N} ${D}http://$IP${N} ${Y}(not started)${N}" fi - echo " │ 📡 SSH: ssh user@$IP (password: archipelago) │" + cc "${C}ssh${N} ${D}archipelago@$IP${N}" else - echo " │ 🌐 Web UI: (no network) │" + cc "${D}no network detected${N}" fi - echo " └─────────────────────────────────────────────────────────────┘" + echo "" - - echo " Main Menu:" - echo " ─────────────────────────────────────────────────────────────" + hrule echo "" - echo " r) Refresh - Update IP/status (no restart needed)" - echo " w) Open Web UI - Launch graphical interface" + cc "${D}r${N} refresh status ${D}w${N} start web ui" echo "" - echo " 1) Install to Disk - Permanently install Archipelago" - echo " 2) Setup Bitcoin Core - Configure Bitcoin full node" - echo " 3) Setup Lightning (LND) - Configure Lightning Network" - echo " 4) Setup BTCPay Server - Bitcoin payment processor" - echo " 5) View Logs - Monitor running services" - echo " 6) Network Settings - Configure networking" - echo " 7) System Info - View system information" + cc "${O}1${N} install to disk ${O}5${N} view logs" + cc "${O}2${N} setup bitcoin core ${O}6${N} network settings" + cc "${O}3${N} setup lightning ${O}7${N} system info" + cc "${O}4${N} setup btcpay server" echo "" - echo " q) Quit" + cc "${D}q quit${N}" echo "" - read -p " Select option: " choice - + + local pad=$(( (TW - 18) / 2 )) + [ $pad -lt 0 ] && pad=0 + printf "%*s" "$pad" "" + read -p "select option: " choice + case $choice in r|R) - # Refresh - just loop again to show updated IP/status ;; w|W) echo "" - # Start the real backend on port 5678 if command -v archipelago >/dev/null 2>&1; then if pgrep -f "archipelago" >/dev/null 2>&1; then - echo " ✅ Archipelago backend already running on port 5678" + cc "${G}backend already running${N}" else - echo " 🚀 Starting Archipelago backend on port 5678..." + cc "${D}starting backend on port 5678...${N}" nohup archipelago >/tmp/archipelago.log 2>&1 & sleep 2 if pgrep -f "archipelago" >/dev/null 2>&1; then - echo " ✅ Backend started!" + cc "${G}backend started${N}" else - echo " ⚠️ Failed to start backend. Check /tmp/archipelago.log" + cc "${R}failed — see /tmp/archipelago.log${N}" fi fi IP=$(hostname -I 2>/dev/null | awk '{print $1}') echo "" - echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 🌐 Open in browser: http://$IP:5678 │" - echo " └─────────────────────────────────────────────────────────────┘" + cc "open in browser: ${W}http://$IP${N}" else - echo " ⚠️ Archipelago binary not found at /usr/local/bin/archipelago" - echo "" - echo " Try running:" - echo " sudo cp /run/live/medium/archipelago/bin/archipelago /usr/local/bin/" + cc "${R}binary not found at /usr/local/bin/archipelago${N}" fi echo "" - read -p " Press Enter to continue..." + read -sp " press enter to continue..." ;; 1) if [ -f "$SCRIPT_DIR/install-to-disk.sh" ]; then sudo bash "$SCRIPT_DIR/install-to-disk.sh" else - echo "Installer not found. Running from: $SCRIPT_DIR" + echo " installer not found at: $SCRIPT_DIR" fi - read -p "Press Enter to continue..." + read -sp " press enter to continue..." ;; 2) if [ -f "$SCRIPT_DIR/setup-bitcoin.sh" ]; then bash "$SCRIPT_DIR/setup-bitcoin.sh" else - echo "Bitcoin setup script not found." + echo " bitcoin setup script not found." fi - read -p "Press Enter to continue..." + read -sp " press enter to continue..." ;; 3) if [ -f "$SCRIPT_DIR/setup-lnd.sh" ]; then bash "$SCRIPT_DIR/setup-lnd.sh" else - echo "LND setup script not found." + echo " lnd setup script not found." fi - read -p "Press Enter to continue..." + read -sp " press enter to continue..." ;; 4) setup_btcpay - read -p "Press Enter to continue..." + read -sp " press enter to continue..." ;; 5) view_logs ;; 6) network_settings - read -p "Press Enter to continue..." + read -sp " press enter to continue..." ;; 7) system_info - read -p "Press Enter to continue..." + read -sp " press enter to continue..." ;; q|Q) - echo "" - echo " Goodbye! 🏝️" echo "" exit 0 ;; *) - echo "Invalid option" - sleep 1 + sleep 0.5 ;; esac done @@ -222,62 +216,63 @@ main_menu() { setup_btcpay() { show_banner - echo " BTCPay Server Setup" - echo " ─────────────────────────────────────────────────────────────" + cc "${W}btcpay server setup${N}" + cc "${D}self-hosted bitcoin payment processor${N}" echo "" - echo " BTCPay Server is a self-hosted Bitcoin payment processor." - echo "" - + if ! podman ps | grep -q bitcoind; then - echo " ⚠️ Bitcoin Core must be running first." + cc "${R}bitcoin core must be running first${N}" return fi - - read -p " Setup BTCPay Server? [y/N]: " SETUP + + local pad=$(( (TW - 30) / 2 )) + [ $pad -lt 0 ] && pad=0 + printf "%*s" "$pad" "" + read -p "setup btcpay server? [y/N]: " SETUP if [[ ! "$SETUP" =~ ^[Yy]$ ]]; then return fi - + echo "" - echo " 🐳 Pulling BTCPay Server image..." + cc "${D}pulling btcpay server image...${N}" podman pull "${BTCPAY_IMAGE}" - - # Create data directory mkdir -p ~/.btcpay - + echo "" - echo " BTCPay Server setup is more complex and typically uses docker-compose." - echo " For a full setup, visit: https://docs.btcpayserver.org" + cc "${D}full setup: https://docs.btcpayserver.org${N}" echo "" } view_logs() { show_banner - echo " View Logs" - echo " ─────────────────────────────────────────────────────────────" + cc "${W}view logs${N}" echo "" - echo " 1) Bitcoin Core logs" - echo " 2) LND logs" - echo " 3) System logs" - echo " b) Back" + cc "${O}1${N} ${D}bitcoin core${N}" + cc "${O}2${N} ${D}lnd${N}" + cc "${O}3${N} ${D}system journal${N}" + cc "${D}b back${N}" echo "" - read -p " Select: " choice - + + local pad=$(( (TW - 10) / 2 )) + [ $pad -lt 0 ] && pad=0 + printf "%*s" "$pad" "" + read -p "select: " choice + case $choice in 1) if podman ps -a | grep -q bitcoind; then podman logs -f --tail 50 bitcoind else - echo "Bitcoin Core not running" - read -p "Press Enter..." + cc "${D}bitcoin core not running${N}" + read -sp " press enter..." fi ;; 2) if podman ps -a | grep -q lnd; then podman logs -f --tail 50 lnd else - echo "LND not running" - read -p "Press Enter..." + cc "${D}lnd not running${N}" + read -sp " press enter..." fi ;; 3) @@ -288,57 +283,61 @@ view_logs() { network_settings() { show_banner - echo " Network Settings" - echo " ─────────────────────────────────────────────────────────────" + cc "${W}network settings${N}" echo "" - - # Show current IP + IP=$(hostname -I | awk '{print $1}') - echo " Current IP: $IP" + cc "${C}ip${N} ${W}$IP${N}" echo "" - - # Show network interfaces - echo " Network Interfaces:" + + cc "${D}interfaces:${N}" ip -br addr | grep -v "^lo" | while read line; do - echo " $line" + cc " ${D}$line${N}" done echo "" - - echo " Ports in use:" - echo " 8332 - Bitcoin RPC" - echo " 8333 - Bitcoin P2P" - echo " 9735 - Lightning P2P" - echo " 10009 - Lightning gRPC" - echo " 8080 - Lightning REST" + + cc "${D}service ports:${N}" + cc " ${D}8332 bitcoin rpc 9735 lightning p2p${N}" + cc " ${D}8333 bitcoin p2p 10009 lightning grpc${N}" echo "" } system_info() { show_banner - echo " System Information" - echo " ─────────────────────────────────────────────────────────────" + cc "${W}system information${N}" echo "" - echo " Hostname: $(hostname)" - echo " Kernel: $(uname -r)" - echo " Uptime: $(uptime -p)" + + cc "${C}host${N} ${D}$(hostname)${N}" + cc "${C}kernel${N} ${D}$(uname -r)${N}" + cc "${C}uptime${N} ${D}$(uptime -p 2>/dev/null || echo 'unknown')${N}" echo "" - echo " CPU: $(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)" - echo " Memory: $(free -h | grep Mem | awk '{print $2}') total, $(free -h | grep Mem | awk '{print $3}') used" + + local cpu=$(grep "model name" /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs) + [ -n "$cpu" ] && cc "${C}cpu${N} ${D}${cpu}${N}" + + local mem_total=$(free -h 2>/dev/null | grep Mem | awk '{print $2}') + local mem_used=$(free -h 2>/dev/null | grep Mem | awk '{print $3}') + [ -n "$mem_total" ] && cc "${C}memory${N} ${D}${mem_used} / ${mem_total}${N}" echo "" - echo " Disk Usage:" - df -h / | tail -1 | awk '{print " Root: " $3 " / " $2 " (" $5 " used)"}' + + cc "${D}disk:${N}" + df -h / | tail -1 | awk '{printf " root: %s / %s (%s used)\n", $3, $2, $5}' | while read line; do + cc "${D}${line}${N}" + done + if [ -d ~/.bitcoin ]; then - echo " Bitcoin: $(du -sh ~/.bitcoin 2>/dev/null | cut -f1)" + local btc_size=$(du -sh ~/.bitcoin 2>/dev/null | cut -f1) + cc " ${D}bitcoin: $btc_size${N}" fi echo "" - - # Container status - echo " Containers:" + if command -v podman >/dev/null 2>&1; then - podman ps --format " {{.Names}}: {{.Status}}" 2>/dev/null || echo " No containers running" + cc "${D}containers:${N}" + podman ps --format " {{.Names}}: {{.Status}}" 2>/dev/null | while read line; do + cc "${D}${line}${N}" + done fi echo "" } -# Run main menu main_menu diff --git a/image-recipe/branding/generate-grub-background.py b/image-recipe/branding/generate-grub-background.py deleted file mode 100644 index cdab6ce4..00000000 --- a/image-recipe/branding/generate-grub-background.py +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env python3 -"""Generate Archipelago GRUB boot background — 80s pixel cyberpunk aesthetic. - -Outputs a 1024x768 PNG with: -- Near-black background with subtle radial gradient -- Scanline overlay (CRT effect) -- Pixel-art "A" logo (from Archipelago SVG) rendered in neon orange -- Neon glow effect around the logo -- Retro grid lines at the bottom (Tron-style horizon) -- Subtle vignette - -Uses only PIL (Pillow) — no external dependencies. -""" -import struct -import zlib -import math -import sys -import os - -W, H = 1024, 768 - -# Archipelago brand colors -BG_DARK = (5, 5, 10) # Near-black with blue tint -BG_MID = (10, 10, 18) # Slightly lighter center -ORANGE = (251, 146, 60) # #fb923c — primary accent -ORANGE_DIM = (180, 100, 30) # Dimmed orange for glow -CYAN = (60, 200, 220) # Cyberpunk accent -MAGENTA = (180, 60, 180) # Cyberpunk accent 2 -GRID_COLOR = (30, 60, 80) # Subtle teal grid -SCANLINE = (0, 0, 0) # Black scanlines - -# The pixel-art "a" (lowercase) from the Archipelago SVG favicon -# Matched to the actual SVG path — it's a blocky pixel-art lowercase "a" -LOGO_A = [ - [0, 1, 1, 1, 1, 0], - [0, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1], - [1, 0, 0, 0, 0, 1], - [1, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1], -] - -# Pixel-art text: "archipelago" — 5-pixel-high bitmap font (lowercase) -PIXEL_CHARS = { - 'a': [[0,1,1,0],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]], - 'r': [[1,0,1,1],[1,1,0,0],[1,0,0,0],[1,0,0,0],[1,0,0,0]], - 'c': [[0,1,1,0],[1,0,0,0],[1,0,0,0],[1,0,0,0],[0,1,1,0]], - 'h': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1]], - 'i': [[0,1],[0,0],[0,1],[0,1],[0,1]], - 'p': [[1,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[1,0,0,0]], - 'e': [[0,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[0,1,1,0]], - 'l': [[1,0],[1,0],[1,0],[1,0],[1,1]], - 'g': [[0,1,1,1],[1,0,0,1],[0,1,1,1],[0,0,0,1],[1,1,1,0]], - 'o': [[0,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,0]], - ' ': [[0,0],[0,0],[0,0],[0,0],[0,0]], -} -LOGO_TEXT = "archipelago" - - -def lerp_color(c1, c2, t): - """Linearly interpolate between two RGB colors.""" - return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2)) - - -def distance(x1, y1, x2, y2): - return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) - - -def create_png(width, height, pixels): - """Create a PNG file from raw RGB pixel data without PIL.""" - - def make_chunk(chunk_type, data): - chunk = chunk_type + data - return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xFFFFFFFF) - - # PNG signature - signature = b'\x89PNG\r\n\x1a\n' - - # IHDR - ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0) # 8-bit RGB - ihdr = make_chunk(b'IHDR', ihdr_data) - - # IDAT — raw pixel data with filter byte per row - raw_data = bytearray() - for y in range(height): - raw_data.append(0) # filter: none - offset = y * width * 3 - raw_data.extend(pixels[offset:offset + width * 3]) - - compressed = zlib.compress(bytes(raw_data), 9) - idat = make_chunk(b'IDAT', compressed) - - # IEND - iend = make_chunk(b'IEND', b'') - - return signature + ihdr + idat + iend - - -def generate(): - # Create pixel buffer (RGB, row-major) - pixels = bytearray(W * H * 3) - - cx, cy = W // 2, H // 2 - 40 # Center, slightly above middle - - # --- Background: radial gradient --- - max_dist = distance(0, 0, cx, cy) - for y in range(H): - for x in range(W): - d = distance(x, y, cx, cy) / max_dist - d = min(d, 1.0) - bg = lerp_color(BG_MID, BG_DARK, d * d) # Quadratic falloff - - # Vignette — darken edges - vx = abs(x - cx) / (W / 2) - vy = abs(y - cy) / (H / 2) - vignette = max(0, 1.0 - (vx * vx + vy * vy) * 0.4) - r = int(bg[0] * vignette) - g = int(bg[1] * vignette) - b = int(bg[2] * vignette) - - idx = (y * W + x) * 3 - pixels[idx] = max(0, min(255, r)) - pixels[idx + 1] = max(0, min(255, g)) - pixels[idx + 2] = max(0, min(255, b)) - - # --- Retro grid (bottom third) --- - horizon_y = H * 2 // 3 - for y in range(horizon_y, H): - depth = (y - horizon_y) / (H - horizon_y) # 0 at horizon, 1 at bottom - - # Horizontal grid lines — spacing decreases with perspective - grid_spacing = max(4, int(40 * (1.0 - depth * 0.8))) - is_hline = ((y - horizon_y) % grid_spacing) < 1 - - for x in range(W): - # Vertical grid lines — converge toward center - spread = 0.3 + depth * 0.7 # Lines spread more toward bottom - grid_x = (x - cx) / (spread * W / 2) * 12 - is_vline = abs(grid_x - round(grid_x)) < 0.04 - - if is_hline or is_vline: - alpha = 0.15 + depth * 0.25 # Brighter closer to viewer - idx = (y * W + x) * 3 - for c in range(3): - old = pixels[idx + c] - pixels[idx + c] = min(255, int(old + GRID_COLOR[c] * alpha)) - - # --- Horizon glow line --- - for x in range(W): - dx = abs(x - cx) / (W / 2) - intensity = max(0, 1.0 - dx * 1.5) * 0.4 - for dy in range(-2, 3): - y = horizon_y + dy - if 0 <= y < H: - falloff = 1.0 - abs(dy) / 3.0 - idx = (y * W + x) * 3 - pixels[idx] = min(255, int(pixels[idx] + CYAN[0] * intensity * falloff)) - pixels[idx + 1] = min(255, int(pixels[idx + 1] + CYAN[1] * intensity * falloff)) - pixels[idx + 2] = min(255, int(pixels[idx + 2] + CYAN[2] * intensity * falloff)) - - # --- Pixel-art "A" logo --- - logo_rows = len(LOGO_A) - logo_cols = len(LOGO_A[0]) - pixel_size = 14 - logo_w = logo_cols * pixel_size - logo_h = logo_rows * pixel_size - logo_x = cx - logo_w // 2 - logo_y = 80 - - # Glow behind logo - glow_radius = 90 - for y in range(max(0, logo_y - glow_radius), min(H, logo_y + logo_h + glow_radius)): - for x in range(max(0, logo_x - glow_radius), min(W, logo_x + logo_w + glow_radius)): - # Distance to logo bounding box - dx = max(0, logo_x - x, x - (logo_x + logo_w)) - dy = max(0, logo_y - y, y - (logo_y + logo_h)) - d = math.sqrt(dx * dx + dy * dy) - if d < glow_radius: - alpha = (1.0 - d / glow_radius) ** 2 * 0.15 - idx = (y * W + x) * 3 - pixels[idx] = min(255, int(pixels[idx] + ORANGE[0] * alpha)) - pixels[idx + 1] = min(255, int(pixels[idx + 1] + ORANGE[1] * alpha)) - pixels[idx + 2] = min(255, int(pixels[idx + 2] + ORANGE[2] * alpha)) - - # Draw logo pixels with 3D depth/shadow effect - shadow_offset = 3 # Pixel offset for 3D shadow - for row in range(logo_rows): - for col in range(logo_cols): - if LOGO_A[row][col]: - px = logo_x + col * pixel_size - py = logo_y + row * pixel_size - # Shadow layer (dark, offset down-right) - for dy in range(pixel_size - 1): - for dx in range(pixel_size - 1): - x = px + dx + shadow_offset - y = py + dy + shadow_offset - if 0 <= x < W and 0 <= y < H: - idx = (y * W + x) * 3 - pixels[idx] = max(0, pixels[idx] - 5) - pixels[idx + 1] = min(255, pixels[idx + 1] + 15) - pixels[idx + 2] = min(255, pixels[idx + 2] + 20) - # Main pixel with highlight gradient (brighter at top-left) - for dy in range(pixel_size - 1): - for dx in range(pixel_size - 1): - x, y = px + dx, py + dy - if 0 <= x < W and 0 <= y < H: - # Gradient: top-left bright, bottom-right darker - t = (dx + dy) / (2 * pixel_size) - r = int(ORANGE[0] * (1.0 - t * 0.3)) - g = int(ORANGE[1] * (1.0 - t * 0.3)) - b = int(ORANGE[2] * (1.0 - t * 0.3)) - # Top-left highlight for 3D bevel - if dx < 2 or dy < 2: - r = min(255, r + 40) - g = min(255, g + 30) - b = min(255, b + 10) - idx = (y * W + x) * 3 - pixels[idx] = r - pixels[idx + 1] = g - pixels[idx + 2] = b - - # --- Pixel-art text "archipelago" below logo --- - text_pixel = 4 # Smaller pixels for text - text_gap = 2 # Gap between characters in pixels - # Calculate total text width - total_w = 0 - for ch in LOGO_TEXT: - char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' ']) - total_w += len(char_data[0]) * text_pixel + text_gap - total_w -= text_gap # No gap after last char - - text_x = cx - total_w // 2 - text_y = logo_y + logo_h + 20 # Below the logo - - cursor_x = text_x - for ch in LOGO_TEXT: - char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' ']) - char_h = len(char_data) - char_w = len(char_data[0]) - for row in range(char_h): - for col in range(char_w): - if char_data[row][col]: - px = cursor_x + col * text_pixel - py = text_y + row * text_pixel - for dy in range(text_pixel - 1): - for dx in range(text_pixel - 1): - x, y = px + dx, py + dy - if 0 <= x < W and 0 <= y < H: - idx = (y * W + x) * 3 - # Dimmer orange for text - pixels[idx] = ORANGE_DIM[0] - pixels[idx + 1] = ORANGE_DIM[1] - pixels[idx + 2] = ORANGE_DIM[2] - cursor_x += char_w * text_pixel + text_gap - - # --- Decorative neon lines flanking the text --- - line_y = text_y + 5 * text_pixel + 12 - line_w = total_w + 40 - line_x1 = cx - line_w // 2 - line_x2 = cx + line_w // 2 - for x in range(line_x1, line_x2): - if 0 <= x < W: - # Fade at edges - edge_dist = min(x - line_x1, line_x2 - x) - alpha = min(1.0, edge_dist / 30.0) * 0.5 - for dy in range(2): - y = line_y + dy - if 0 <= y < H: - idx = (y * W + x) * 3 - pixels[idx] = min(255, int(pixels[idx] + CYAN[0] * alpha * 0.3)) - pixels[idx + 1] = min(255, int(pixels[idx + 1] + CYAN[1] * alpha * 0.3)) - pixels[idx + 2] = min(255, int(pixels[idx + 2] + CYAN[2] * alpha * 0.3)) - - # --- "self-sovereign bitcoin infrastructure" tagline --- - TAG_CHARS = { - 's': [[0,1,1],[1,0,0],[0,1,0],[0,0,1],[1,1,0]], - 'f': [[0,1,1],[1,0,0],[1,1,0],[1,0,0],[1,0,0]], - '-': [[0,0,0],[0,0,0],[1,1,1],[0,0,0],[0,0,0]], - 'v': [[1,0,1],[1,0,1],[1,0,1],[0,1,0],[0,1,0]], - 'n': [[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1]], - 'b': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,1,1,0]], - 't': [[0,1,0],[1,1,1],[0,1,0],[0,1,0],[0,0,1]], - 'd': [[0,0,0,1],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]], - 'u': [[1,0,0,1],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,1]], - } - # Merge with existing chars - all_chars = {**PIXEL_CHARS, **TAG_CHARS} - tagline = "self-sovereign bitcoin node" - tag_pixel = 3 - tag_gap = 2 - tag_total = sum(len(all_chars.get(c, all_chars[' '])[0]) * tag_pixel + tag_gap for c in tagline) - tag_gap - tag_x = cx - tag_total // 2 - tag_y = line_y + 8 - tag_cursor = tag_x - for ch in tagline: - char_data = all_chars.get(ch, all_chars[' ']) - char_h = len(char_data) - char_w = len(char_data[0]) - for row in range(char_h): - for col in range(char_w): - if char_data[row][col]: - px = tag_cursor + col * tag_pixel - py = tag_y + row * tag_pixel - for dy in range(tag_pixel - 1): - for dx in range(tag_pixel - 1): - x, y = px + dx, py + dy - if 0 <= x < W and 0 <= y < H: - idx = (y * W + x) * 3 - pixels[idx] = min(255, pixels[idx] + 40) - pixels[idx + 1] = min(255, pixels[idx + 1] + 50) - pixels[idx + 2] = min(255, pixels[idx + 2] + 55) - tag_cursor += char_w * tag_pixel + tag_gap - - # --- Scanlines (every other row, subtle) --- - for y in range(0, H, 2): - for x in range(W): - idx = (y * W + x) * 3 - pixels[idx] = int(pixels[idx] * 0.92) - pixels[idx + 1] = int(pixels[idx + 1] * 0.92) - pixels[idx + 2] = int(pixels[idx + 2] * 0.92) - - # --- Generate PNG --- - png_data = create_png(W, H, bytes(pixels)) - return png_data - - -if __name__ == '__main__': - out_path = sys.argv[1] if len(sys.argv) > 1 else 'background.png' - png_data = generate() - with open(out_path, 'wb') as f: - f.write(png_data) - print(f'Generated {out_path} ({len(png_data)} bytes)') diff --git a/image-recipe/branding/generate-plymouth-logo.py b/image-recipe/branding/generate-plymouth-logo.py deleted file mode 100644 index a05f9884..00000000 --- a/image-recipe/branding/generate-plymouth-logo.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the Archipelago Plymouth boot logo — pixel-art 'a' with neon glow. - -Outputs a 256x256 PNG with transparent background (RGBA). -""" -import struct -import zlib -import math -import sys - -W, H = 256, 256 -ORANGE = (251, 146, 60) -ORANGE_BRIGHT = (255, 180, 100) - -# Lowercase pixel-art "a" — 6x6 grid -LOGO_A = [ - [0, 1, 1, 1, 1, 0], - [0, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1], - [1, 0, 0, 0, 0, 1], - [1, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1], -] - -# "archipelago" text — same pixel font as GRUB generator -PIXEL_CHARS = { - 'a': [[0,1,1,0],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]], - 'r': [[1,0,1,1],[1,1,0,0],[1,0,0,0],[1,0,0,0],[1,0,0,0]], - 'c': [[0,1,1,0],[1,0,0,0],[1,0,0,0],[1,0,0,0],[0,1,1,0]], - 'h': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1]], - 'i': [[0,1],[0,0],[0,1],[0,1],[0,1]], - 'p': [[1,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[1,0,0,0]], - 'e': [[0,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[0,1,1,0]], - 'l': [[1,0],[1,0],[1,0],[1,0],[1,1]], - 'g': [[0,1,1,1],[1,0,0,1],[0,1,1,1],[0,0,0,1],[1,1,1,0]], - 'o': [[0,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,0]], - ' ': [[0,0],[0,0],[0,0],[0,0],[0,0]], -} - - -def create_png_rgba(width, height, pixels): - """Create a PNG with RGBA pixel data.""" - def make_chunk(chunk_type, data): - chunk = chunk_type + data - return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xFFFFFFFF) - - signature = b'\x89PNG\r\n\x1a\n' - ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0) # 8-bit RGBA - ihdr = make_chunk(b'IHDR', ihdr_data) - - raw_data = bytearray() - for y in range(height): - raw_data.append(0) - offset = y * width * 4 - raw_data.extend(pixels[offset:offset + width * 4]) - - compressed = zlib.compress(bytes(raw_data), 9) - idat = make_chunk(b'IDAT', compressed) - iend = make_chunk(b'IEND', b'') - - return signature + ihdr + idat + iend - - -def generate(): - pixels = bytearray(W * H * 4) # RGBA - - cx, cy = W // 2, W // 2 - 30 - - logo_rows = len(LOGO_A) - logo_cols = len(LOGO_A[0]) - pixel_size = 18 - logo_w = logo_cols * pixel_size - logo_h = logo_rows * pixel_size - logo_x = cx - logo_w // 2 - logo_y = 30 - - # Glow - glow_radius = 60 - for y in range(H): - for x in range(W): - dx = max(0, logo_x - x, x - (logo_x + logo_w)) - dy = max(0, logo_y - y, y - (logo_y + logo_h)) - d = math.sqrt(dx * dx + dy * dy) - if d < glow_radius: - alpha = (1.0 - d / glow_radius) ** 2 * 0.25 - idx = (y * W + x) * 4 - pixels[idx] = ORANGE[0] - pixels[idx + 1] = ORANGE[1] - pixels[idx + 2] = ORANGE[2] - pixels[idx + 3] = int(alpha * 255) - - # Logo pixels with 3D bevel - for row in range(logo_rows): - for col in range(logo_cols): - if LOGO_A[row][col]: - px = logo_x + col * pixel_size - py = logo_y + row * pixel_size - for dy in range(pixel_size - 1): - for dx in range(pixel_size - 1): - x, y = px + dx, py + dy - if 0 <= x < W and 0 <= y < H: - t = (dx + dy) / (2 * pixel_size) - r = int(ORANGE[0] * (1.0 - t * 0.3)) - g = int(ORANGE[1] * (1.0 - t * 0.3)) - b = int(ORANGE[2] * (1.0 - t * 0.3)) - if dx < 2 or dy < 2: - r = min(255, r + 40) - g = min(255, g + 30) - b = min(255, b + 10) - idx = (y * W + x) * 4 - pixels[idx] = r - pixels[idx + 1] = g - pixels[idx + 2] = b - pixels[idx + 3] = 255 - - # Text "archipelago" below logo - text = "archipelago" - text_pixel = 3 - text_gap = 2 - total_w = sum(len(PIXEL_CHARS.get(c, PIXEL_CHARS[' '])[0]) * text_pixel + text_gap for c in text) - text_gap - text_x = cx - total_w // 2 - text_y = logo_y + logo_h + 16 - cursor = text_x - for ch in text: - char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' ']) - for row in range(len(char_data)): - for col in range(len(char_data[0])): - if char_data[row][col]: - for dy in range(text_pixel - 1): - for dx in range(text_pixel - 1): - x = cursor + col * text_pixel + dx - y = text_y + row * text_pixel + dy - if 0 <= x < W and 0 <= y < H: - idx = (y * W + x) * 4 - pixels[idx] = 180 - pixels[idx + 1] = 100 - pixels[idx + 2] = 30 - pixels[idx + 3] = 200 - cursor += len(char_data[0]) * text_pixel + text_gap - - return create_png_rgba(W, H, bytes(pixels)) - - -if __name__ == '__main__': - out_path = sys.argv[1] if len(sys.argv) > 1 else 'logo.png' - data = generate() - with open(out_path, 'wb') as f: - f.write(data) - print(f'Generated {out_path} ({len(data)} bytes)') diff --git a/image-recipe/branding/grub-theme/background.png b/image-recipe/branding/grub-theme/background.png index bbc584f9..019d951d 100644 Binary files a/image-recipe/branding/grub-theme/background.png and b/image-recipe/branding/grub-theme/background.png differ diff --git a/image-recipe/branding/grub-theme/theme.txt b/image-recipe/branding/grub-theme/theme.txt index f630781f..c2fc1c8d 100644 --- a/image-recipe/branding/grub-theme/theme.txt +++ b/image-recipe/branding/grub-theme/theme.txt @@ -23,7 +23,7 @@ desktop-image: "background.png" left = 25% top = 20% width = 50% - text = "A R C H I P E L A G O" + text = "a r c h i p e l a g o" color = "#f7931a" align = "center" } @@ -32,7 +32,7 @@ desktop-image: "background.png" left = 25% top = 28% width = 50% - text = "Bitcoin Node OS" + text = "bitcoin node os" color = "#888888" align = "center" } @@ -41,7 +41,7 @@ desktop-image: "background.png" left = 25% top = 90% width = 50% - text = "Use arrow keys to select, Enter to boot" + text = "use arrow keys to select, enter to boot" color = "#555555" align = "center" } diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 03ee3335..c3ae0a3c 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -324,6 +324,10 @@ RUN mkdir -p /etc/archipelago/ssl && \ COPY archipelago.service /etc/systemd/system/archipelago.service COPY archipelago-update.service /etc/systemd/system/archipelago-update.service COPY archipelago-update.timer /etc/systemd/system/archipelago-update.timer +COPY archipelago-doctor.service /etc/systemd/system/archipelago-doctor.service +COPY archipelago-doctor.timer /etc/systemd/system/archipelago-doctor.timer +COPY archipelago-reconcile.service /etc/systemd/system/archipelago-reconcile.service +COPY archipelago-reconcile.timer /etc/systemd/system/archipelago-reconcile.timer # Enable services RUN systemctl enable NetworkManager || true && \ @@ -333,7 +337,9 @@ RUN systemctl enable NetworkManager || true && \ systemctl enable tor || true && \ systemctl enable tailscaled || true && \ systemctl enable chrony || true && \ - systemctl enable archipelago-update.timer || true + systemctl enable archipelago-update.timer || true && \ + systemctl enable archipelago-doctor.timer || true && \ + systemctl enable archipelago-reconcile.timer || true # Remove policy-rc.d so services can start on first boot RUN rm -f /usr/sbin/policy-rc.d @@ -393,6 +399,15 @@ NGINXCONF echo " Using archipelago-update.service + timer from configs/" fi + # Copy container doctor and reconciliation timers + if [ -f "$SCRIPT_DIR/configs/archipelago-doctor.service" ]; then + cp "$SCRIPT_DIR/configs/archipelago-doctor.service" "$WORK_DIR/archipelago-doctor.service" + cp "$SCRIPT_DIR/configs/archipelago-doctor.timer" "$WORK_DIR/archipelago-doctor.timer" + cp "$SCRIPT_DIR/configs/archipelago-reconcile.service" "$WORK_DIR/archipelago-reconcile.service" + cp "$SCRIPT_DIR/configs/archipelago-reconcile.timer" "$WORK_DIR/archipelago-reconcile.timer" + echo " Using container doctor + reconcile timers from configs/" + fi + # Use archipelago.service from configs/ (User=root for Podman container access) if [ -f "$SCRIPT_DIR/configs/archipelago.service" ]; then cp "$SCRIPT_DIR/configs/archipelago.service" "$WORK_DIR/archipelago.service" @@ -519,11 +534,23 @@ if [ -n "\$INSTALLER_STARTED" ]; then fi export INSTALLER_STARTED=1 +# Colors +O=\$'\\033[1;33m' # Bold yellow (orange accent) +W=\$'\\033[1;37m' # Bold white +D=\$'\\033[37m' # Dim +N=\$'\\033[0m' # Reset + +# Center-print +TW=\$(tput cols 2>/dev/null || echo 60) +[ "\$TW" -gt 120 ] && TW=120 +cc() { local s=\$(echo -e "\$1" | sed 's/\\x1b\\[[0-9;]*m//g'); local p=\$(( (TW - \${#s}) / 2 )); [ \$p -lt 0 ] && p=0; printf "%*s" "\$p" ""; echo -e "\$1"; } + sleep 1 clear echo "" -echo " ARCHIPELAGO BITCOIN NODE OS" -echo " Automatic Installer" +cc "\${W}a r c h i p e l a g o\${N}" +cc "\${O}━━━━━━━━━━━━━━━━━━━━━\${N}" +cc "\${D}Automatic Installer\${N}" echo "" BOOT_MEDIA="" @@ -535,17 +562,14 @@ for dev in /run/live/medium /lib/live/mount/medium /run/archiso /cdrom /media/cd done if [ -n "\$BOOT_MEDIA" ]; then - echo " Found installer at: \$BOOT_MEDIA" + cc "\${D}Found installer at: \$BOOT_MEDIA\${N}" echo "" - echo " Press Enter to start installation, or Ctrl+C for shell..." - read + cc "Press Enter to install | \${W}Ctrl+C\${N} for shell" + read -s bash "\$BOOT_MEDIA/archipelago/auto-install.sh" else - echo " Installer not found on boot media." - echo " Checked: /run/live/medium, /run/archiso, /cdrom, /media/cdrom" - echo "" - echo " You can try manually:" - echo " sudo bash /path/to/archipelago/auto-install.sh" + cc "\${D}Installer not found on boot media.\${N}" + cc "\${D}Try: sudo bash /path/to/archipelago/auto-install.sh\${N}" echo "" fi PROFILE @@ -1349,26 +1373,62 @@ case "$(uname -m)" in ;; esac -# Colors (use $'...' syntax for reliable escape code interpretation) -RED=$'\033[0;31m' -GREEN=$'\033[0;32m' +# Colors (basic ANSI — works on bare-metal Linux console) +RED=$'\033[31m' +GREEN=$'\033[32m' YELLOW=$'\033[1;33m' -BLUE=$'\033[0;34m' +ORANGE=$'\033[1;33m' +DIM=$'\033[37m' +CYAN=$'\033[36m' +WHITE=$'\033[1;37m' NC=$'\033[0m' +# Adaptive centering +TW=$(tput cols 2>/dev/null || echo 60) +[ "$TW" -gt 120 ] && TW=120 +cc() { local s=$(echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g'); local p=$(( (TW - ${#s}) / 2 )); [ $p -lt 0 ] && p=0; printf "%*s" "$p" ""; echo -e "$1"; } +hrule() { local len=$((TW > 50 ? 50 : TW - 4)); local hr=""; for i in $(seq 1 $len); do hr="${hr}─"; done; cc "${DIM}${hr}${NC}"; } + +box() { + local bw=$((TW > 52 ? 52 : TW - 4)) + local inner=$((bw - 2)) + local top="╭"; local bot="╰" + for i in $(seq 1 $inner); do top="${top}─"; bot="${bot}─"; done + top="${top}╮"; bot="${bot}╯" + cc "${DIM}${top}${NC}" +} +boxend() { + local bw=$((TW > 52 ? 52 : TW - 4)) + local inner=$((bw - 2)) + local bot="╰" + for i in $(seq 1 $inner); do bot="${bot}─"; done + bot="${bot}╯" + cc "${DIM}${bot}${NC}" +} +boxline() { + local bw=$((TW > 52 ? 52 : TW - 4)) + local inner=$((bw - 2)) + local stripped=$(echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g') + local pad=$((inner - ${#stripped})) + [ $pad -lt 0 ] && pad=0 + local right="" + for i in $(seq 1 $pad); do right="${right} "; done + cc "${DIM}│${NC} $1${right}${DIM}│${NC}" +} + clear echo "" -echo -e "${BLUE}╔═══════════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ ║${NC}" -echo -e "${BLUE}║ ${GREEN}🏝️ ARCHIPELAGO BITCOIN NODE OS${BLUE} ║${NC}" -echo -e "${BLUE}║ ║${NC}" -echo -e "${BLUE}║ ${NC}Automatic Installation${BLUE} ║${NC}" -echo -e "${BLUE}║ ║${NC}" -echo -e "${BLUE}╚═══════════════════════════════════════════════════════════════════╝${NC}" +box +boxline "" +boxline "${WHITE}A R C H I P E L A G O${NC}" +boxline "${ORANGE}━━━━━━━━━━━━━━━━━━━━━${NC}" +boxline "${DIM}Automatic Installation${NC}" +boxline "" +boxend echo "" # Check required tools are present (should be bundled in ISO) -echo -e "${YELLOW}🔧 Checking installer tools...${NC}" +cc "${DIM}Checking installer tools...${NC}" MISSING="" command -v parted >/dev/null 2>&1 || MISSING="parted $MISSING" command -v mkfs.vfat >/dev/null 2>&1 || MISSING="mkfs.vfat $MISSING" @@ -1467,15 +1527,15 @@ if [ -z "$TARGET_DISK" ]; then fi echo "" -echo -e "${GREEN}✅ Target disk: $TARGET_DISK ($TARGET_SIZE)${NC}" +cc "${GREEN}target: ${WHITE}$TARGET_DISK ($TARGET_SIZE)${NC}" echo "" -echo -e "${RED}⚠️ WARNING: ALL DATA ON $TARGET_DISK WILL BE ERASED${NC}" +cc "${RED}all data on $TARGET_DISK will be erased${NC}" echo "" -echo "Press Enter to install Archipelago, or Ctrl+C to cancel..." -read +cc "${DIM}Press Enter to install | Ctrl+C to cancel${NC}" +read -s echo "" -echo -e "${YELLOW}🔧 Installing Archipelago...${NC}" +cc "${DIM}Installing Archipelago...${NC}" echo "" # Unmount any existing partitions @@ -2402,6 +2462,9 @@ date=$(date -u '+%Y-%m-%d %H:%M:%S UTC') type=unbundled BUILDINFO +# Save install log BEFORE unmounting target +cp "$INSTALL_LOG" /mnt/target/var/log/archipelago-install.log 2>/dev/null || true + # Cleanup sync umount /mnt/target/run 2>/dev/null || true @@ -2415,63 +2478,34 @@ cryptsetup close archipelago-data 2>/dev/null || true umount /mnt/target 2>/dev/null || true echo "" -echo -e "${GREEN} _${NC}" -echo -e "${GREEN} ,--.\\\`-. __${NC}" -echo -e "${GREEN} _,.\\\`. \\:/,\" \\\`-._${NC}" -echo -e "${GREEN} ,-*\" _,.-;-*\\\`-.+\"*._ )${NC}" -echo -e "${GREEN} ( ,.\"* ,-\" / \\\`. \\\\. \\\`.${NC}" -echo -e "${GREEN} ,\" ,;\" ,\"\\../\\ \\: \\${NC}" -echo -e "${GREEN} ( ,\"/ / \\\\.,' : )) /${NC}" -echo -e "${GREEN} \\ |/ / \\\\.,' / // ,'${NC}" -echo -e "${GREEN} \\_)\\ ,' \\\\.,' ( / )/${NC}" -echo -e "${GREEN} \\\` \\._,' \\\`\"${NC}" -echo -e "${GREEN} \\../${NC}" -echo -e "${GREEN} \\../${NC}" -echo -e "${GREEN} ~ ~\\../ ~~ ~~${NC}" -echo -e "${GREEN} ~~ ~~ \\../ ~~ ~ ~~${NC}" -echo -e "${GREEN} ~~ ~ ~~ __...---\\../-...__ ~~~ ~~${NC}" -echo -e "${GREEN} ~~~~ ~_,--' \\../ \\\`--.__ ~~ ~~${NC}" -echo -e "${GREEN} ~~~ __,--' \\\`\" \\\`--.__ ~~~${NC}" -echo -e "${GREEN}~~ ,--' \\\`--.${NC}" -echo -e "${GREEN} '------......______ ______......------\\\` ~~${NC}" -echo -e "${GREEN} ~~~ ~ ~~ ~ \\\`\\\`\\\`\\\`\\\`---\"\"\"\"\" ~~ ~ ~~${NC}" -echo -e "${GREEN} ~~~~ ~~ ~~~~ ~~~~~~ ~ ~~ ~~ ~~~ ~${NC}" echo "" -echo -e "${GREEN} █████╗ ██████╗ ██████╗██╗ ██╗██╗██████╗ ███████╗██╗ █████╗ ██████╗ ██████╗ ${NC}" -echo -e "${GREEN} ██╔══██╗██╔══██╗██╔════╝██║ ██║██║██╔══██╗██╔════╝██║ ██╔══██╗██╔════╝ ██╔═══██╗${NC}" -echo -e "${GREEN} ███████║██████╔╝██║ ███████║██║██████╔╝█████╗ ██║ ███████║██║ ███╗██║ ██║${NC}" -echo -e "${GREEN} ██╔══██║██╔══██╗██║ ██╔══██║██║██╔═══╝ ██╔══╝ ██║ ██╔══██║██║ ██║██║ ██║${NC}" -echo -e "${GREEN} ██║ ██║██║ ██║╚██████╗██║ ██║██║██║ ███████╗███████╗██║ ██║╚██████╔╝╚██████╔╝${NC}" -echo -e "${GREEN} ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ${NC}" +box +boxline "" +boxline "${WHITE}A R C H I P E L A G O${NC}" +boxline "${GREEN}━━━━━━━━━━━━━━━━━━━━━${NC}" +boxline "${GREEN}Installation Complete${NC}" +boxline "" +boxend echo "" -echo -e "${GREEN} 🏝️ BITCOIN NODE OS 🏝️${NC}" +cc "${DIM}After reboot, open the Web UI from any device on your network.${NC}" echo "" -echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════════╗${NC}" -echo -e "${GREEN}║ ✅ INSTALLATION COMPLETE! ║${NC}" -echo -e "${GREEN}║ ║${NC}" -echo -e "${GREEN}║ Remove the USB drive and press Enter to reboot. ║${NC}" -echo -e "${GREEN}║ ║${NC}" -echo -e "${GREEN}║ After reboot: ║${NC}" -echo -e "${GREEN}║ • Web UI: http:// ║${NC}" -echo -e "${GREEN}║ • SSH: ssh archipelago@ ║${NC}" -echo -e "${GREEN}║ • SSH Password: archipelago ║${NC}" -echo -e "${GREEN}║ • Web Password: password123 ║${NC}" -echo -e "${GREEN}║ ║${NC}" -echo -e "${GREEN}║ Pre-loaded apps (ready to start via Web UI): ║${NC}" -echo -e "${GREEN}║ • Bitcoin Knots • LND • Home Assistant ║${NC}" -echo -e "${GREEN}║ • BTCPay Server • Mempool • Nostr Relays ║${NC}" -echo -e "${GREEN}║ ║${NC}" -echo -e "${GREEN}║ Validate: bash /opt/archipelago/scripts/run-e2e-tests.sh ║${NC}" -echo -e "${GREEN}║ ║${NC}" -echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════════╝${NC}" +cc "${DIM}Web UI:${NC} ${WHITE}http://${NC}" +cc "${DIM}SSH:${NC} ${DIM}ssh archipelago@${NC}" +cc "${DIM}Password:${NC} ${DIM}archipelago${NC}" +cc "${DIM}Web Login:${NC} ${DIM}password123${NC}" echo "" +cc "${DIM}Pre-loaded apps (start via Web UI):${NC}" +cc "${DIM}Bitcoin Knots, LND, Home Assistant,${NC}" +cc "${DIM}BTCPay Server, Mempool, Nostr Relay${NC}" echo "" -echo -e "${YELLOW} >>> REMOVE THE USB DRIVE NOW <<<${NC}" +hrule echo "" -# Save install log to target disk for post-install debugging -cp "$INSTALL_LOG" /mnt/target/var/log/archipelago-install.log 2>/dev/null || true +cc "${YELLOW}>>> REMOVE THE USB DRIVE NOW <<<${NC}" +echo "" +# Install log already saved before unmount (above) -read -p "Press Enter to reboot (make sure USB is removed)..." +cc "${DIM}Press Enter to reboot${NC}" +read -s # Suppress all error output during cleanup and reboot exec 2>/dev/null @@ -2590,22 +2624,25 @@ UI vesamenu.c32 PROMPT 0 TIMEOUT 0 -MENU TITLE ARCHIPELAGO - Bitcoin Node OS +MENU TITLE Bitcoin Node OS MENU BACKGROUND splash.png MENU RESOLUTION 1024 768 -MENU VSHIFT 14 -MENU HSHIFT 6 -MENU WIDTH 40 +MENU VSHIFT 15 +MENU HSHIFT 28 +MENU WIDTH 26 +MENU MARGIN 2 +MENU ROWS 5 +MENU TABMSG Press TAB to edit | https://archipelago.sh MENU COLOR screen 37;40 #00000000 #00000000 none MENU COLOR border 30;40 #00000000 #00000000 none -MENU COLOR title 1;37;40 #fffb923c #00000000 none -MENU COLOR sel 7;37;40 #ffffffff #80333333 std -MENU COLOR unsel 37;40 #ff999999 #00000000 none +MENU COLOR title 1;37;40 #80888888 #00000000 none +MENU COLOR sel 7;37;40 #ffffffff #c0181818 std +MENU COLOR unsel 37;40 #ffaaaaaa #00000000 none MENU COLOR hotkey 1;37;40 #fffb923c #00000000 none -MENU COLOR hotsel 1;37;40 #fffb923c #80333333 std -MENU COLOR timeout_msg 37;40 #ff666666 #00000000 none +MENU COLOR hotsel 1;37;40 #fffb923c #c0181818 std +MENU COLOR timeout_msg 37;40 #ff555555 #00000000 none MENU COLOR timeout 1;37;40 #fffb923c #00000000 none -MENU COLOR tabmsg 37;40 #ff666666 #00000000 none +MENU COLOR tabmsg 37;40 #ff444444 #00000000 none MENU COLOR cmdmark 37;40 #00000000 #00000000 none MENU COLOR cmdline 37;40 #00000000 #00000000 none @@ -2618,7 +2655,7 @@ LABEL install MENU DEFAULT LABEL install-verbose - MENU LABEL Install Archipelago (verbose) + MENU LABEL Install (verbose output) KERNEL /live/vmlinuz APPEND initrd=/live/initrd.img boot=live components diff --git a/image-recipe/configs/archipelago-doctor.service b/image-recipe/configs/archipelago-doctor.service new file mode 100644 index 00000000..667e133b --- /dev/null +++ b/image-recipe/configs/archipelago-doctor.service @@ -0,0 +1,12 @@ +[Unit] +Description=Archipelago Container Doctor +After=archipelago.service + +[Service] +Type=oneshot +# Runs as root: needs to kill orphaned conmon processes, fix permissions +User=root +ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local +TimeoutStartSec=120 +StandardOutput=journal +StandardError=journal diff --git a/image-recipe/configs/archipelago-doctor.timer b/image-recipe/configs/archipelago-doctor.timer new file mode 100644 index 00000000..26906149 --- /dev/null +++ b/image-recipe/configs/archipelago-doctor.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Archipelago container doctor (periodic) + +[Timer] +# First run 5 minutes after boot, then every 30 minutes +OnBootSec=5min +OnUnitActiveSec=30min +# Jitter to avoid load spikes +RandomizedDelaySec=60 + +[Install] +WantedBy=timers.target diff --git a/image-recipe/configs/archipelago-reconcile.service b/image-recipe/configs/archipelago-reconcile.service new file mode 100644 index 00000000..c45b1f0e --- /dev/null +++ b/image-recipe/configs/archipelago-reconcile.service @@ -0,0 +1,14 @@ +[Unit] +Description=Archipelago Container Reconciliation +After=archipelago.service + +[Service] +Type=oneshot +User=archipelago +Environment="XDG_RUNTIME_DIR=/run/user/1000" +Environment="HOME=/home/archipelago" +Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +ExecStart=/home/archipelago/archy/scripts/reconcile-containers.sh +TimeoutStartSec=600 +StandardOutput=journal +StandardError=journal diff --git a/image-recipe/configs/archipelago-reconcile.timer b/image-recipe/configs/archipelago-reconcile.timer new file mode 100644 index 00000000..7b9d7d8e --- /dev/null +++ b/image-recipe/configs/archipelago-reconcile.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Archipelago container reconciliation (periodic) + +[Timer] +# First run 10 minutes after boot, then every 6 hours +OnBootSec=10min +OnUnitActiveSec=6h +# Jitter to avoid load spikes +RandomizedDelaySec=300 +# Run missed checks on boot +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index ab357b48..4e69581b 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -16,6 +16,8 @@ Restart=on-failure RestartSec=5 WatchdogSec=300 TimeoutStartSec=300 +# Bitcoin Core needs up to 600s to flush UTXO set on shutdown +TimeoutStopSec=660 # Filesystem protection ProtectSystem=strict diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index b3b5e2e9..e7eb684b 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -62,6 +62,11 @@ class RPCClient { // Use a single shared timeout to prevent redirect storms when // multiple parallel requests all get 401 at once if (response.status === 401 && method !== 'auth.login') { + // Clear stale auth immediately — stops App.vue watcher from + // firing more requests and prevents the router from + // optimistically navigating to /dashboard + try { localStorage.removeItem('neode-auth') } catch { /* noop */ } + const isOnboarding = window.location.pathname.startsWith('/onboarding') console.warn(`[RPC] 401 on ${method} | path=${window.location.pathname} | onboarding=${isOnboarding} | redirecting=${RPCClient._sessionExpiredRedirecting}`) if (!isOnboarding && !RPCClient._sessionExpiredRedirecting) {