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:
parent
6311aa563e
commit
99400a7165
15
core/Cargo.lock
generated
15
core/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)");
|
||||
""
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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")?;
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)')
|
||||
@ -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 |
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
12
image-recipe/configs/archipelago-doctor.service
Normal file
12
image-recipe/configs/archipelago-doctor.service
Normal 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
|
||||
12
image-recipe/configs/archipelago-doctor.timer
Normal file
12
image-recipe/configs/archipelago-doctor.timer
Normal 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
|
||||
14
image-recipe/configs/archipelago-reconcile.service
Normal file
14
image-recipe/configs/archipelago-reconcile.service
Normal 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
|
||||
14
image-recipe/configs/archipelago-reconcile.timer
Normal file
14
image-recipe/configs/archipelago-reconcile.timer
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user