security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed): - CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed - HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted - HIGH: tar slip prevention, S3 SSRF validation, backup ID validation - MEDIUM: remember-me random secret, TOTP session rotation, password re-auth - LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation Container reliability: - Memory limits on all 37 containers (OOM prevention) - Exited vs stopped state distinction with health-aware status badges - Crash recovery coordination (no more restart cascade) - User-stopped tracking survives reboots - Tiered boot recovery (databases → core → services → apps) UI: - Wallet TransactionsModal, health-aware app status badges - Restart button on containers, exited/crashed red state - Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch - Apps sticky header removed, dev faucet, mutable mock wallet Infrastructure: - LND REST port 8080 exposed over Tor (LND Connect fix) - Nginx cookie_session fix, deploy script Tor config updated - Dev environment: podman auto-start, boot mode simulation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d1b48388fb
commit
1a74a930f7
@@ -11,11 +11,64 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const PID_FILE: &str = "archipelago.pid";
|
||||
const CONTAINER_STATE_FILE: &str = "running-containers.json";
|
||||
const USER_STOPPED_FILE: &str = "user-stopped.json";
|
||||
|
||||
/// Shared flag: true once boot recovery is complete. Health monitor should wait for this.
|
||||
pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish.
|
||||
pub fn mark_recovery_complete() {
|
||||
RECOVERY_COMPLETE.store(true, Ordering::SeqCst);
|
||||
info!("Boot recovery complete — health monitor may proceed");
|
||||
}
|
||||
|
||||
/// Check if boot recovery is done.
|
||||
pub fn is_recovery_complete() -> bool {
|
||||
RECOVERY_COMPLETE.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
// ── User-stopped tracking ───────────────────────────────────────────────
|
||||
// When a user explicitly stops a container via the UI, we record it here
|
||||
// so crash recovery and health monitor don't auto-restart it.
|
||||
|
||||
/// Load the set of user-stopped containers from disk.
|
||||
pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||
let path = data_dir.join(USER_STOPPED_FILE);
|
||||
match fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the set of user-stopped containers to disk.
|
||||
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
|
||||
let path = data_dir.join(USER_STOPPED_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(stopped) {
|
||||
let _ = fs::write(&path, json).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a container as user-stopped (won't be auto-restarted).
|
||||
pub async fn mark_user_stopped(data_dir: &Path, name: &str) {
|
||||
let mut stopped = load_user_stopped(data_dir).await;
|
||||
stopped.insert(name.to_string());
|
||||
save_user_stopped(data_dir, &stopped).await;
|
||||
}
|
||||
|
||||
/// Clear user-stopped flag (container was manually started by user).
|
||||
pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
||||
let mut stopped = load_user_stopped(data_dir).await;
|
||||
if stopped.remove(name) {
|
||||
save_user_stopped(data_dir, &stopped).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunningContainerRecord {
|
||||
@@ -241,7 +294,8 @@ fn is_process_running(pid: u32) -> bool {
|
||||
/// Start all stopped containers that were previously installed.
|
||||
/// Runs on every startup to ensure containers come back after clean reboots.
|
||||
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
||||
pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
/// Skips containers that the user intentionally stopped via the UI.
|
||||
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
@@ -257,7 +311,7 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
}
|
||||
};
|
||||
|
||||
let names: Vec<String> = match output {
|
||||
let all_names: Vec<String> = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
@@ -268,17 +322,52 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
if all_names.is_empty() {
|
||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||
}
|
||||
|
||||
// Filter out user-stopped containers
|
||||
let user_stopped = load_user_stopped(data_dir).await;
|
||||
let names: Vec<String> = all_names.into_iter()
|
||||
.filter(|n| {
|
||||
if user_stopped.contains(n) {
|
||||
info!("Skipping user-stopped container: {}", n);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||
}
|
||||
|
||||
info!("Starting {} stopped containers after boot...", names.len());
|
||||
let records: Vec<RunningContainerRecord> = names.iter()
|
||||
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||
let mut records: Vec<RunningContainerRecord> = names.iter()
|
||||
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
|
||||
.collect();
|
||||
records.sort_by_key(|r| container_boot_tier(&r.name));
|
||||
|
||||
info!("Starting {} stopped containers after boot (skipped {} user-stopped)...",
|
||||
records.len(), user_stopped.len());
|
||||
recover_containers(&records).await
|
||||
}
|
||||
|
||||
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||
fn container_boot_tier(name: &str) -> u8 {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
match id {
|
||||
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
|
||||
| "immich_redis" | "penpot-valkey" => 0,
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
|
||||
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" => 4,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically saves the container snapshot.
|
||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
|
||||
Reference in New Issue
Block a user