fix: CSRF race condition, UI containers, Tor ordering, seed layout
Some checks failed
Container Orchestration Tests / unit-tests (push) Has been cancelled
Container Orchestration Tests / smoke-tests (push) Has been cancelled
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled

- session.rs: use OnceCell for remember_secret to prevent concurrent
  requests on first boot from generating different HMAC secrets, which
  caused CSRF token mismatch on every state-changing RPC call (app
  install, start, stop all failed with "CSRF token missing or invalid")

- install.rs: write lnd.conf with Bitcoin RPC credentials before LND
  container starts (prevents "bitcoin.mainnet must be specified" crash);
  inject Bitcoin RPC auth into bitcoin-ui nginx.conf; add proper error
  logging to UI container build/run steps; fix UI containers to use
  --network=host (they proxy to localhost backend/bitcoin RPC)

- Tor: remove After=tor.service from archipelago-tor-helper.path to
  break systemd ordering cycle that prevented Tor from starting on boot

- Seed screen: compact grid layout (2 cols mobile, 4 cols sm+) with
  tighter padding to fit kiosk displays without scrolling

- Dockerfiles: remove nonexistent assets/ COPY from bitcoin-ui, fix
  electrs-ui to COPY qrcode.js and EXPOSE 50002 (matches nginx.conf)

- image-versions.sh: add UI container image variables for registry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-31 11:06:19 +01:00
parent 030015fce6
commit 4b0e1cfbe3
9 changed files with 182 additions and 45 deletions

View File

@@ -13,6 +13,24 @@ use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{debug, info, warn};
const INSTALL_LOG: &str = "/var/log/archipelago-container-installs.log";
/// Append a timestamped line to the persistent install log.
async fn install_log(msg: &str) {
let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let line = format!("[{}] {}\n", ts, msg);
let _ = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(INSTALL_LOG)
.await
.and_then(|mut f| {
use tokio::io::AsyncWriteExt;
Box::pin(async move { f.write_all(line.as_bytes()).await })
})
.await;
}
impl RpcHandler {
/// Install a package from a Docker image.
/// Security: Image verification, resource limits, network isolation.
@@ -33,12 +51,14 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
install_log(&format!("INSTALL START: {} (image: {})", package_id, docker_image)).await;
debug!(
"Installing package {} from image {}",
package_id, docker_image
);
if !is_valid_docker_image(docker_image) {
install_log(&format!("INSTALL FAIL: {} — invalid image format", package_id)).await;
return Err(anyhow::anyhow!("Invalid Docker image format"));
}
@@ -80,9 +100,11 @@ impl RpcHandler {
}
// Pull or verify image
install_log(&format!("INSTALL PULL: {} — pulling image {}", package_id, docker_image)).await;
let has_local_fallback = self
.pull_or_verify_image(package_id, docker_image)
.await?;
install_log(&format!("INSTALL PULL OK: {} — image ready (local_fallback={})", package_id, has_local_fallback)).await;
// Normalize container name for legacy aliases
let container_name = match package_id {
@@ -173,6 +195,11 @@ impl RpcHandler {
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
}
// Pre-install: lnd.conf with Bitcoin RPC credentials
if package_id == "lnd" {
self.write_lnd_conf(&rpc_user, &rpc_pass).await;
}
// Pre-install: SearXNG settings.yml (required or container exits immediately)
if package_id == "searxng" {
let searx_dir = "/var/lib/archipelago/searxng";
@@ -244,6 +271,7 @@ impl RpcHandler {
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr);
install_log(&format!("INSTALL FAIL: {} — podman run failed: {}", package_id, stderr)).await;
// Rollback: remove partially created container
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
@@ -305,6 +333,8 @@ impl RpcHandler {
// Post-install hooks — await completion before returning success
self.run_post_install_hooks(package_id).await;
install_log(&format!("INSTALL OK: {} (container: {})", package_id, &container_id[..12.min(container_id.len())])).await;
Ok(serde_json::json!({
"success": true,
"package_id": package_id,
@@ -544,6 +574,47 @@ printtoconsole=1\n",
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
}
/// Write LND config file with Bitcoin RPC credentials.
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) {
let lnd_dir = "/var/lib/archipelago/lnd";
let conf_path = format!("{}/lnd.conf", lnd_dir);
// Don't overwrite existing config (user may have customized it)
if tokio::fs::try_exists(&conf_path).await.unwrap_or(false) {
info!("lnd.conf already exists, skipping write");
return;
}
let lnd_conf = format!(
"\
[Application Options]\n\
listen=0.0.0.0:9735\n\
rpclisten=0.0.0.0:10009\n\
restlisten=0.0.0.0:8080\n\
debuglevel=info\n\
noseedbackup=true\n\
\n\
[Bitcoin]\n\
bitcoin.mainnet=true\n\
bitcoin.node=bitcoind\n\
\n\
[Bitcoind]\n\
bitcoind.rpchost=bitcoin-knots:8332\n\
bitcoind.rpcuser={user}\n\
bitcoind.rpcpass={pass}\n\
bitcoind.rpcpolling=true\n\
bitcoind.estimatemode=ECONOMICAL\n\
\n\
[autopilot]\n\
autopilot.active=false\n",
user = rpc_user,
pass = rpc_pass,
);
let _ = tokio::fs::create_dir_all(lnd_dir).await;
let _ = tokio::fs::write(&conf_path, lnd_conf).await;
info!("Created lnd.conf with Bitcoin RPC credentials");
}
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
async fn run_post_install_hooks(&self, package_id: &str) {
@@ -647,54 +718,105 @@ printtoconsole=1\n",
info!("Nextcloud trusted domains configured for {}", host_ip);
}
// Build and start companion UI containers for headless services
let ui_builds: Vec<(&str, &str, &str, &str)> = match package_id {
// Pre-build: inject Bitcoin RPC auth into bitcoin-ui nginx.conf
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
use base64::Engine;
let auth_b64 = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", rpc_user, rpc_pass));
for dir in ["/opt/archipelago/docker/bitcoin-ui", "/home/archipelago/archy/docker/bitcoin-ui"] {
let conf_path = format!("{}/nginx.conf", dir);
if let Ok(content) = tokio::fs::read_to_string(&conf_path).await {
if content.contains("__BITCOIN_RPC_AUTH__") {
let updated = content.replace("__BITCOIN_RPC_AUTH__", &auth_b64);
let _ = tokio::fs::write(&conf_path, updated).await;
info!("Injected Bitcoin RPC auth into {}", conf_path);
}
}
}
}
// Build and start companion UI containers for headless services.
// All UIs proxy to localhost (backend :5678 or bitcoin :8332) so they need --network=host.
let ui_builds: Vec<(&str, &str, &str)> = match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
vec![("bitcoin-ui", "/opt/archipelago/docker/bitcoin-ui", "localhost/bitcoin-ui", "8334:80")]
vec![("archy-bitcoin-ui", "/opt/archipelago/docker/bitcoin-ui", "bitcoin-ui")]
}
"lnd" => {
vec![("archy-lnd-ui", "/opt/archipelago/docker/lnd-ui", "localhost/lnd-ui", "8081:80")]
vec![("archy-lnd-ui", "/opt/archipelago/docker/lnd-ui", "lnd-ui")]
}
"electrumx" | "electrs" | "mempool-electrs" => {
vec![("archy-electrs-ui", "/opt/archipelago/docker/electrs-ui", "localhost/electrs-ui", "50002:80")]
vec![("archy-electrs-ui", "/opt/archipelago/docker/electrs-ui", "electrs-ui")]
}
_ => vec![],
};
for (name, ui_dir, image, port) in ui_builds {
for (name, ui_dir, image_base) in ui_builds {
let name = name.to_string();
let ui_dir = ui_dir.to_string();
let image = image.to_string();
let port = port.to_string();
let image_base = image_base.to_string();
let registry = "80.71.235.15:3000/archipelago";
let registry_image = format!("{}/{}:latest", registry, image_base);
let local_image = format!("localhost/{}:latest", image_base);
tokio::spawn(async move {
if !std::path::Path::new(&ui_dir).exists() {
info!("UI source not found at {}, skipping", ui_dir);
return;
}
info!("Building UI container {} from {}", name, ui_dir);
let _ = tokio::process::Command::new("podman")
.args(["build", "-t", &image, &ui_dir])
.output()
.await;
// Remove existing container
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", &name])
.output()
.await;
let _ = tokio::process::Command::new("podman")
// Try registry image first, fall back to local build
let image = {
let pull = tokio::process::Command::new("podman")
.args(["pull", &registry_image])
.output()
.await;
if pull.map_or(false, |o| o.status.success()) {
info!("Pulled {} UI from registry", name);
registry_image.clone()
} else if std::path::Path::new(&ui_dir).exists() {
info!("Registry pull failed, building {} from {}", name, ui_dir);
let build = tokio::process::Command::new("podman")
.args(["build", "-t", &local_image, &ui_dir])
.output()
.await;
match build {
Ok(o) if o.status.success() => local_image,
Ok(o) => {
warn!("Failed to build {}: {}", name,
String::from_utf8_lossy(&o.stderr));
return;
}
Err(e) => {
warn!("Failed to build {}: {}", name, e);
return;
}
}
} else {
warn!("No registry image or source for {} — skipping", name);
return;
}
};
// Run with --network=host (UIs proxy to localhost backend/bitcoin)
let run = tokio::process::Command::new("podman")
.args([
"run", "-d",
"--name", &name,
"--restart=unless-stopped",
"--network=archy-net",
"--network=host",
"--cap-drop=ALL",
"--cap-add=NET_BIND_SERVICE",
"--memory=64m",
"-p", &port,
&format!("{}:latest", image),
&image,
])
.output()
.await;
info!("{} UI container started on port {}", name, port);
match run {
Ok(o) if o.status.success() => info!("{} UI container started (host network)", name),
Ok(o) => warn!("Failed to start {}: {}", name, String::from_utf8_lossy(&o.stderr)),
Err(e) => warn!("Failed to start {}: {}", name, e),
}
});
}
}

View File

@@ -50,6 +50,12 @@ impl DockerPackageScanner {
"immich_redis",
"endurain-db",
"nextcloud-db",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-build_api_1",
"indeedhub-build_postgres_1",
"indeedhub-build_redis_1",

View File

@@ -5,9 +5,12 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use tokio::sync::{OnceCell, RwLock};
use zeroize::Zeroize;
/// Cached remember secret — loaded once, never regenerated within a process.
static REMEMBER_SECRET: OnceCell<Vec<u8>> = OnceCell::const_new();
type HmacSha256 = Hmac<Sha256>;
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
@@ -391,21 +394,23 @@ impl SessionStore {
}
pub async fn load_or_create_remember_secret() -> Vec<u8> {
// Try existing secret file first
if let Ok(secret) = tokio::fs::read(REMEMBER_SECRET_FILE).await {
if secret.len() == 32 {
return secret;
REMEMBER_SECRET.get_or_init(|| async {
// Try existing secret file first
if let Ok(secret) = tokio::fs::read(REMEMBER_SECRET_FILE).await {
if secret.len() == 32 {
return secret;
}
}
}
// Generate a cryptographically random 32-byte secret on first boot
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
// Ensure parent directory exists
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(REMEMBER_SECRET_FILE, &secret).await;
secret.to_vec()
// Generate a cryptographically random 32-byte secret on first boot
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
// Ensure parent directory exists
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(REMEMBER_SECRET_FILE, &secret).await;
secret.to_vec()
}).await.clone()
}
}

View File

@@ -1,7 +1,6 @@
FROM 80.71.235.15:3000/archipelago/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8334
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,7 +1,7 @@
FROM 80.71.235.15:3000/archipelago/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
COPY qrcode.js /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 50002
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,6 +1,5 @@
[Unit]
Description=Watch for Archipelago Tor management actions
After=tor.service
[Path]
PathExists=/var/lib/archipelago/tor-config/tor-action

View File

@@ -36,14 +36,14 @@
<!-- Word Grid -->
<div v-if="words.length > 0" class="w-full max-w-[600px]">
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 gap-1.5 sm:gap-2">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
<div
v-for="(word, i) in words"
:key="i"
class="bg-black/60 rounded-lg px-3 py-1.5 sm:py-2 border border-white/10"
class="bg-black/60 rounded-lg px-2.5 py-1 sm:py-1.5 border border-white/10"
>
<span class="text-white/40 text-[1rem] font-mono mr-1.5">{{ i + 1 }}.</span>
<span class="text-white/95 text-[1.2rem] font-mono">{{ word }}</span>
<span class="text-white/40 text-sm font-mono mr-1">{{ i + 1 }}.</span>
<span class="text-white/95 text-[1.05rem] font-mono">{{ word }}</span>
</div>
</div>

View File

@@ -12,6 +12,7 @@ export const SERVICE_NAMES = new Set([
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',

View File

@@ -81,5 +81,10 @@ PENPOT_BACKEND_IMAGE="$ARCHY_REGISTRY/penpot-backend:2.4"
PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
# Custom UI containers (built from docker/ dirs, pushed to registry)
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:latest"
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:latest"
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:latest"
# Base images
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"