feat: container orchestration, branding overhaul, onboarding logging

Container orchestration:
- Health monitor with crash recovery and auto-restart
- Doctor service (periodic health checks via systemd timer)
- Reconcile service (desired-state convergence)
- Stack-aware install/uninstall with dependency tracking

Branding:
- Custom GRUB background (designer artwork, 1024x768)
- ISOLINUX boot menu: centered, orange accents, clean labels
- Terminal banners: adaptive width, basic ANSI colors, fits 80-col
- Removed auto-generated splash scripts (designer provides assets)
- GRUB theme: lowercase branding

Frontend:
- 401 handler clears localStorage immediately (prevents cascade)

Backend:
- Onboarding/auth logging ([onboarding] tag in journalctl)
- Cookie Secure flag logging for debugging HTTP/HTTPS issues

ISO fixes:
- Install log saved before unmount (was silently failing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-28 11:31:48 +00:00
parent 6311aa563e
commit 99400a7165
22 changed files with 626 additions and 763 deletions

15
core/Cargo.lock generated
View File

@@ -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"

View File

@@ -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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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))
}
}

View File

@@ -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)");
""
}

View File

@@ -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::<String>()
));
}
}
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;

View File

@@ -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")?;

View File

@@ -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<serde_json::Value> {
@@ -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")

View File

@@ -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::<String>()
);
}
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 {

View File

@@ -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<String, ContainerRestartRecord>,
}
#[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<String, u64> {
let output = match tokio::time::timeout(
@@ -373,6 +437,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, 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<StateManager>, 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<StateManager>, 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<StateManager>, 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<StateManager>, 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;
}
}
});
}

View File

@@ -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;
});
}

View File

@@ -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.
}
}
_ => {}

View File

@@ -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

View File

@@ -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)')

View File

@@ -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)')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 837 KiB

View File

@@ -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"
}

View File

@@ -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://<IP> ║${NC}"
echo -e "${GREEN}║ • SSH: ssh archipelago@<IP> ║${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://<this machine's IP>${NC}"
cc "${DIM}SSH:${NC} ${DIM}ssh archipelago@<IP>${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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) {