fix: CSRF race condition, UI containers, Tor ordering, seed layout
- 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:
@@ -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", ®istry_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),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user