From 3e9c192b48d1f1c3fff00d6b9979bfac199e5e20 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 23 Apr 2026 02:19:52 -0400 Subject: [PATCH] feat(container): bitcoin-ui pre-start hook renders nginx.conf from embedded template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the first-boot-containers.sh sed/envsubst approach with a Rust-native render step bound into the ContainerOrchestrator lifecycle. - New container::bitcoin_ui module: embeds the nginx.conf template via include_str!, reads the plaintext RPC password from /var/lib/archipelago/secrets/bitcoin-rpc-password, substitutes {{BITCOIN_RPC_AUTH}} with base64(archipelago:), and atomic- writes (tmp + rename) to /var/lib/archipelago/bitcoin-ui/nginx.conf. Idempotent: byte-compares before writing so unchanged input is a no-op (no inode churn, no restart cascade). - ProdContainerOrchestrator gains run_pre_start_hooks(app_id) returning HookOutcome::{Rewritten, Unchanged}. Fires in install_fresh before create_container, and in ensure_running: on Running + Rewritten triggers a restart; on Stopped re-renders then starts. - bitcoin-ui Dockerfile no longer COPYs a default.conf; the file now arrives via runtime bind-mount of the rendered config. If the bind- mount is ever missing, nginx starts with no site configured and returns 404 everywhere — safe failure vs. serving upstream RPC with a stale Authorization header. - apps/{bitcoin,electrs,lnd}-ui/manifest.yml land as first-class manifests. bitcoin-ui declares the bind-mount target and a dependency on bitcoin-core; electrs-ui and lnd-ui declare their own deps and health checks. - 8 new unit tests on the render fn (idempotency, rotation, trimming, missing/empty secret, template invariants) plus an integration test asserting install(bitcoin-ui) actually lands a substituted nginx.conf on disk via the hook. 39/39 container:: tests pass (test_parse_image_versions pre-existing failure unchanged, out of scope). --- apps/bitcoin-ui/manifest.yml | 56 ++++ apps/electrs-ui/manifest.yml | 38 +++ apps/lnd-ui/manifest.yml | 40 +++ core/archipelago/src/container/bitcoin_ui.rs | 274 ++++++++++++++++++ .../container/bitcoin_ui_nginx.conf.template | 2 +- core/archipelago/src/container/mod.rs | 1 + .../src/container/prod_orchestrator.rs | 155 +++++++++- docker/bitcoin-ui/Dockerfile | 17 +- 8 files changed, 577 insertions(+), 6 deletions(-) create mode 100644 apps/bitcoin-ui/manifest.yml create mode 100644 apps/electrs-ui/manifest.yml create mode 100644 apps/lnd-ui/manifest.yml create mode 100644 core/archipelago/src/container/bitcoin_ui.rs rename docker/bitcoin-ui/nginx.conf => core/archipelago/src/container/bitcoin_ui_nginx.conf.template (90%) diff --git a/apps/bitcoin-ui/manifest.yml b/apps/bitcoin-ui/manifest.yml new file mode 100644 index 00000000..a9e10f4d --- /dev/null +++ b/apps/bitcoin-ui/manifest.yml @@ -0,0 +1,56 @@ +app: + id: bitcoin-ui + name: Bitcoin UI + version: 1.0.0 + description: | + Archipelago-native HTTP proxy + static site for interacting with the + Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container + and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The + upstream Authorization header is substituted from + /var/lib/archipelago/secrets/bitcoin-rpc-password by the prod + orchestrator's pre-start hook, rendered into an nginx.conf that is + bind-mounted read-only at container start. + + container: + build: + context: /opt/archipelago/docker/bitcoin-ui + dockerfile: Dockerfile + tag: localhost/bitcoin-ui:local + + dependencies: + - app_id: bitcoin-core + + resources: + memory_limit: 128Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 8334 directly on the host IP, and + # proxies to 127.0.0.1:8332 which is where the bitcoin backend binds + # its RPC. `ports:` is intentionally empty because host networking + # bypasses port mapping. + ports: [] + + volumes: + # Bind-mount the rendered nginx.conf read-only. The prod orchestrator + # renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install + # and every reconcile pass, substituting the base64 RPC auth from + # the plaintext password secret. If the rendered bytes change (the + # password rotated, or the template was updated by OTA), the + # reconciler restarts this container so nginx re-reads the config. + - type: bind + source: /var/lib/archipelago/bitcoin-ui/nginx.conf + target: /etc/nginx/conf.d/default.conf + options: [ro] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:8334 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/electrs-ui/manifest.yml b/apps/electrs-ui/manifest.yml new file mode 100644 index 00000000..0d5f6be3 --- /dev/null +++ b/apps/electrs-ui/manifest.yml @@ -0,0 +1,38 @@ +app: + id: electrs-ui + name: Electrs UI + version: 1.0.0 + description: | + Archipelago-native HTTP frontend for electrs/electrumx status. Runs + nginx inside a container, serves static assets, and proxies + /electrs-status to the archipelago backend on 127.0.0.1:5678. + + container: + build: + context: /opt/archipelago/docker/electrs-ui + dockerfile: Dockerfile + tag: localhost/electrs-ui:local + + dependencies: [] + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 50002 directly on the host IP. + ports: [] + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:50002 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml new file mode 100644 index 00000000..ff17aa77 --- /dev/null +++ b/apps/lnd-ui/manifest.yml @@ -0,0 +1,40 @@ +app: + id: lnd-ui + name: LND UI + version: 1.0.0 + description: | + Archipelago-native HTTP frontend for LND. Runs nginx inside a + container and serves static assets. LND connection info is fetched + via an absolute URL that the host nginx routes to the archipelago + backend on 127.0.0.1:5678, so no upstream auth is baked in. + + container: + build: + context: /opt/archipelago/docker/lnd-ui + dockerfile: Dockerfile + tag: localhost/lnd-ui:local + + dependencies: + - app_id: lnd + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 8081 directly on the host IP. + ports: [] + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:8081 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/core/archipelago/src/container/bitcoin_ui.rs b/core/archipelago/src/container/bitcoin_ui.rs new file mode 100644 index 00000000..15469efb --- /dev/null +++ b/core/archipelago/src/container/bitcoin_ui.rs @@ -0,0 +1,274 @@ +//! bitcoin-ui nginx.conf renderer. +//! +//! Step 7 of the rust-orchestrator migration. Replaces the old +//! `sed -i __BITCOIN_RPC_AUTH__` approach from `first-boot-containers.sh` +//! (which destructively overwrote its own template, broke on rotation, +//! and had no story for dual Knots/Core UIs) with a binary-embedded +//! template rendered at install/reconcile time and atomic-written to +//! disk. +//! +//! The manifest bind-mounts the rendered file read-only into the +//! container at `/etc/nginx/conf.d/default.conf`. On every reconcile +//! pass we re-render and compare — if the rendered bytes would differ +//! from what's on disk (password rotated, template changed via OTA), +//! we rewrite atomically and the reconciler restarts the container. +//! +//! Source of truth: +//! * RPC user: hardcoded `archipelago` (matches the image's `bitcoin.conf`). +//! * RPC password: `/var/lib/archipelago/secrets/bitcoin-rpc-password`, +//! plaintext, written by the seed-derived credential setup. +//! +//! Both Knots and Core back-ends expose RPC on 127.0.0.1:8332 with the +//! same auth shape, so one template serves both. + +use anyhow::{Context, Result}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// The nginx.conf template. Embedded at compile time so it can never +/// drift from the code that renders it, and ships atomically with OTA. +/// +/// `{{BITCOIN_RPC_AUTH}}` is the only placeholder — replaced with a +/// `base64(user:password)` blob at render time. +pub(crate) const TEMPLATE: &str = include_str!("bitcoin_ui_nginx.conf.template"); + +/// The single placeholder in `TEMPLATE`. +const PLACEHOLDER: &str = "{{BITCOIN_RPC_AUTH}}"; + +/// Hardcoded RPC user. Matches the user written into `bitcoin.conf` by +/// the bitcoin-core/bitcoin-knots bootstrap, and the legacy +/// `BITCOIN_RPC_USER="archipelago"` from `first-boot-containers.sh`. +const RPC_USER: &str = "archipelago"; + +/// Default path to the plaintext RPC password secret. +/// +/// Written by the seed-derived credential flow; same file the bash +/// scripts read today at `first-boot-containers.sh:277` and `:1225`. +pub const DEFAULT_SECRET_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password"; + +/// Default output path for the rendered nginx.conf. +/// +/// The manifest bind-mounts this file read-only into the bitcoin-ui +/// container at `/etc/nginx/conf.d/default.conf`. +pub const DEFAULT_RENDERED_PATH: &str = "/var/lib/archipelago/bitcoin-ui/nginx.conf"; + +/// Parameters for rendering. Injectable so tests can hit a tmpdir +/// instead of `/var/lib/archipelago`. +#[derive(Debug, Clone)] +pub struct RenderPaths { + /// Path to read the plaintext RPC password from. + pub secret_path: PathBuf, + /// Path to write the rendered nginx.conf to. + pub rendered_path: PathBuf, +} + +impl Default for RenderPaths { + fn default() -> Self { + Self { + secret_path: PathBuf::from(DEFAULT_SECRET_PATH), + rendered_path: PathBuf::from(DEFAULT_RENDERED_PATH), + } + } +} + +/// Outcome of a render pass. `Written` if the rendered bytes differed +/// from the current on-disk contents and we rewrote; `Unchanged` if +/// they matched and we left the file alone. +/// +/// The caller (reconciler / install path) decides whether to restart +/// the bitcoin-ui container based on this. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderOutcome { + Written, + Unchanged, +} + +/// Render the bitcoin-ui nginx.conf and atomic-write it to disk if it +/// differs from what's already there. +/// +/// Idempotent: safe to call on every reconcile pass. Does a byte +/// comparison before writing so an unchanged password + template is a +/// no-op (no inode churn, no container restart cascade). +/// +/// Errors if the secret file is missing or empty. Upstream callers +/// treat that as "bitcoin-ui isn't installable yet" rather than fatal +/// — the RPC password comes into being during bitcoin-core's own +/// bootstrap, which may not have happened yet on a fresh node. +pub async fn render(paths: &RenderPaths) -> Result { + let password = read_password(&paths.secret_path).await?; + let auth_b64 = encode_basic_auth(RPC_USER, &password); + let rendered = TEMPLATE.replace(PLACEHOLDER, &auth_b64); + + // Compare against existing. read-to-string fails on ENOENT (first + // install) — treat as "different". + let existing = fs::read_to_string(&paths.rendered_path).await.ok(); + if existing.as_deref() == Some(rendered.as_str()) { + return Ok(RenderOutcome::Unchanged); + } + + // Atomic write: write to sibling tmp + rename. Keeps the bind- + // mounted file pointing at a fully-formed config at all times. + let parent = paths + .rendered_path + .parent() + .ok_or_else(|| anyhow::anyhow!("rendered_path has no parent directory"))?; + fs::create_dir_all(parent) + .await + .with_context(|| format!("creating {}", parent.display()))?; + + let tmp = paths.rendered_path.with_extension("tmp"); + fs::write(&tmp, &rendered) + .await + .with_context(|| format!("writing tmp {}", tmp.display()))?; + fs::rename(&tmp, &paths.rendered_path) + .await + .with_context(|| format!("renaming {} -> {}", tmp.display(), paths.rendered_path.display()))?; + + tracing::info!( + path = %paths.rendered_path.display(), + auth_hash = %short_hash(&auth_b64), + "bitcoin-ui nginx.conf rendered" + ); + + Ok(RenderOutcome::Written) +} + +/// Read the plaintext RPC password from disk. Trims trailing newlines +/// (common from `echo "$PASS" > file`) but rejects an empty result. +async fn read_password(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .await + .with_context(|| format!("reading bitcoin RPC password from {}", path.display()))?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + anyhow::bail!( + "bitcoin RPC password file {} is empty — bitcoin-core bootstrap hasn't written it yet", + path.display() + ); + } + Ok(trimmed) +} + +/// `base64("user:password")` — the value nginx puts after `Basic ` in +/// the upstream `Authorization` header. +fn encode_basic_auth(user: &str, password: &str) -> String { + let raw = format!("{user}:{password}"); + base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()) +} + +/// Short hash of the auth value for logging — we never want the +/// plaintext or full base64 in logs (it's a credential), but a stable +/// fingerprint helps correlate rotations. +fn short_hash(s: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(s.as_bytes()); + let digest = hasher.finalize(); + hex::encode(&digest[..4]) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn paths_in(dir: &Path, password: &str) -> RenderPaths { + let secret = dir.join("bitcoin-rpc-password"); + std::fs::write(&secret, password).unwrap(); + RenderPaths { + secret_path: secret, + rendered_path: dir.join("nginx.conf"), + } + } + + #[tokio::test] + async fn render_writes_file_with_substitution() { + let tmp = TempDir::new().unwrap(); + let paths = paths_in(tmp.path(), "hunter2"); + let outcome = render(&paths).await.unwrap(); + assert_eq!(outcome, RenderOutcome::Written); + let contents = std::fs::read_to_string(&paths.rendered_path).unwrap(); + // archipelago:hunter2 -> "YXJjaGlwZWxhZ286aHVudGVyMg==" + assert!( + contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="), + "base64 auth not found in rendered config:\n{contents}" + ); + assert!(!contents.contains(PLACEHOLDER), "placeholder left in output"); + } + + #[tokio::test] + async fn render_is_idempotent_when_password_unchanged() { + let tmp = TempDir::new().unwrap(); + let paths = paths_in(tmp.path(), "hunter2"); + let first = render(&paths).await.unwrap(); + assert_eq!(first, RenderOutcome::Written); + let second = render(&paths).await.unwrap(); + assert_eq!(second, RenderOutcome::Unchanged); + } + + #[tokio::test] + async fn render_rewrites_on_password_rotation() { + let tmp = TempDir::new().unwrap(); + let paths = paths_in(tmp.path(), "old-pass"); + render(&paths).await.unwrap(); + // Rotate. + std::fs::write(&paths.secret_path, "new-pass").unwrap(); + let outcome = render(&paths).await.unwrap(); + assert_eq!(outcome, RenderOutcome::Written); + let contents = std::fs::read_to_string(&paths.rendered_path).unwrap(); + // archipelago:new-pass -> "YXJjaGlwZWxhZ286bmV3LXBhc3M=" + assert!(contents.contains("YXJjaGlwZWxhZ286bmV3LXBhc3M=")); + } + + #[tokio::test] + async fn render_trims_trailing_newline_from_secret() { + // Matches `echo "$PASS" > file` behaviour. + let tmp = TempDir::new().unwrap(); + let paths = paths_in(tmp.path(), "hunter2\n"); + render(&paths).await.unwrap(); + let contents = std::fs::read_to_string(&paths.rendered_path).unwrap(); + assert!( + contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="), + "trailing newline should be stripped before encoding" + ); + } + + #[tokio::test] + async fn render_errors_on_empty_password() { + let tmp = TempDir::new().unwrap(); + let paths = paths_in(tmp.path(), ""); + let err = render(&paths).await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("empty"), "unexpected error: {msg}"); + } + + #[tokio::test] + async fn render_errors_when_secret_missing() { + let tmp = TempDir::new().unwrap(); + let paths = RenderPaths { + secret_path: tmp.path().join("does-not-exist"), + rendered_path: tmp.path().join("nginx.conf"), + }; + let err = render(&paths).await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("reading bitcoin RPC password"), "unexpected error: {msg}"); + } + + #[test] + fn template_contains_exactly_one_placeholder() { + // Safety net: if someone adds a second placeholder to the + // template without updating the renderer, we want a test to + // fail loudly rather than ship a half-substituted config. + let count = TEMPLATE.matches(PLACEHOLDER).count(); + assert_eq!(count, 1, "template must contain exactly one {PLACEHOLDER}"); + } + + #[test] + fn template_proxies_bitcoin_rpc_on_8332() { + // Lock in the core shape so a bad template edit doesn't ship. + assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/")); + assert!(TEMPLATE.contains("location /bitcoin-rpc/")); + assert!(TEMPLATE.contains("listen 8334")); + } +} diff --git a/docker/bitcoin-ui/nginx.conf b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template similarity index 90% rename from docker/bitcoin-ui/nginx.conf rename to core/archipelago/src/container/bitcoin_ui_nginx.conf.template index a6b99834..97db8a08 100644 --- a/docker/bitcoin-ui/nginx.conf +++ b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template @@ -9,7 +9,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Authorization "Basic __BITCOIN_RPC_AUTH__"; + proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}"; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "POST, GET, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, Authorization"; diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 2f15f9eb..4f13eddd 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -1,3 +1,4 @@ +pub mod bitcoin_ui; pub mod boot_reconciler; pub mod data_manager; pub mod dev_orchestrator; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index e3c835db..f40ecb15 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime}; +use crate::container::bitcoin_ui; use crate::container::traits::ContainerOrchestrator; /// App IDs whose containers are named `archy-` rather than bare ``. @@ -127,6 +128,10 @@ pub struct ProdContainerOrchestrator { runtime: Arc, manifests_dir: PathBuf, state: Arc>, + /// Where the bitcoin-ui pre-start hook reads its secret from and + /// writes the rendered nginx.conf to. Configurable so tests can + /// point the hook at a tmpdir. + bitcoin_ui_paths: bitcoin_ui::RenderPaths, } impl ProdContainerOrchestrator { @@ -156,6 +161,7 @@ impl ProdContainerOrchestrator { runtime, manifests_dir, state: Arc::new(RwLock::new(OrchestratorState::new())), + bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(), }) } @@ -170,9 +176,18 @@ impl ProdContainerOrchestrator { runtime, manifests_dir, state: Arc::new(RwLock::new(OrchestratorState::new())), + bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(), } } + /// Override the bitcoin-ui render paths (secret + output). Only used + /// by tests that exercise the bitcoin-ui pre-start hook — the + /// default `/var/lib/archipelago/...` paths are correct for prod. + #[cfg(test)] + pub fn set_bitcoin_ui_paths(&mut self, paths: bitcoin_ui::RenderPaths) { + self.bitcoin_ui_paths = paths; + } + /// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each, /// validates it, and stores it in the in-memory state. /// @@ -313,8 +328,29 @@ impl ProdContainerOrchestrator { let name = compute_container_name(&lm.manifest); match self.runtime.get_container_status(&name).await { Ok(status) => match status.state { - ContainerState::Running => Ok(ReconcileAction::NoOp), + ContainerState::Running => { + // App-specific hooks get a chance to refresh bind-mounted + // config. bitcoin-ui: re-render nginx.conf if the RPC + // password rotated (or template changed via OTA). If + // anything was rewritten, restart the container so nginx + // picks up the new config. + if let Some(HookOutcome::Rewritten) = self.run_pre_start_hooks(&app_id).await? { + tracing::info!(app_id = %app_id, "config rewritten while running — restarting"); + let _ = self.runtime.stop_container(&name).await; + self.runtime + .start_container(&name) + .await + .with_context(|| format!("reconcile restart {name}"))?; + return Ok(ReconcileAction::Started); + } + Ok(ReconcileAction::NoOp) + } ContainerState::Stopped | ContainerState::Exited => { + // Even for a plain start, re-run the pre-start hooks so + // the bind-mounted file is fresh before nginx starts + // reading it. A Rewritten outcome is fine here — we're + // about to start from a stopped state anyway. + self.run_pre_start_hooks(&app_id).await?; self.runtime .start_container(&name) .await @@ -378,6 +414,12 @@ impl ProdContainerOrchestrator { } let name = compute_container_name(&lm.manifest); + // App-specific pre-create hook (render bind-mounted config files, etc). + // For bitcoin-ui this renders /var/lib/archipelago/bitcoin-ui/nginx.conf + // with the plaintext RPC auth substituted in. Failure is fatal: if we + // can't render the config the bind-mount would resolve to either a + // stale file or a missing path, and nginx would 502 every request. + self.run_pre_start_hooks(&lm.manifest.app.id).await?; // Production orchestrator: no port offset. self.runtime .create_container(&lm.manifest, &name, 0) @@ -396,6 +438,45 @@ impl ProdContainerOrchestrator { // in the `impl ContainerOrchestrator for ProdContainerOrchestrator` block // below — call those through the trait, not as inherent methods. // ------------------------------------------------------------------ + + /// App-specific pre-start hooks. Called before `create_container` on + /// install, and on every reconcile pass (for already-running apps too + /// so we catch RPC password rotation without a manual restart). + /// + /// Returns `Some(Rewritten)` if the hook modified on-disk state that + /// the running container is bind-mounting — the caller uses that + /// signal to decide whether to restart. `Some(Unchanged)` means the + /// hook ran cleanly but nothing on disk changed. `None` means there + /// was no hook registered for this `app_id`. + /// + /// Kept on `ProdContainerOrchestrator` (not extracted into a trait + /// yet) because there's exactly one hook today — a generic pre-start + /// mechanism is worth adding when the second hook appears, not now. + async fn run_pre_start_hooks(&self, app_id: &str) -> Result> { + match app_id { + "bitcoin-ui" => { + let paths = self.bitcoin_ui_paths.clone(); + let outcome = bitcoin_ui::render(&paths) + .await + .context("bitcoin-ui pre-start: render nginx.conf")?; + Ok(Some(match outcome { + bitcoin_ui::RenderOutcome::Written => HookOutcome::Rewritten, + bitcoin_ui::RenderOutcome::Unchanged => HookOutcome::Unchanged, + })) + } + _ => Ok(None), + } + } +} + +/// Result of a pre-start hook pass. See `run_pre_start_hooks` docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HookOutcome { + /// The hook wrote new bytes to a bind-mounted file; the caller + /// should restart the container if it's currently running. + Rewritten, + /// The hook ran cleanly; nothing on disk changed. + Unchanged, } // --------------------------------------------------------------------------- @@ -709,10 +790,33 @@ mod tests { } async fn orch_with(runtime: Arc) -> ProdContainerOrchestrator { - ProdContainerOrchestrator::with_runtime( + let mut orch = ProdContainerOrchestrator::with_runtime( runtime, PathBuf::from("/nonexistent-for-tests"), - ) + ); + // Redirect the bitcoin-ui pre-start hook to a test-scoped + // tmpdir, seeded with a fake password file. Shared across + // every test in this module (OnceLock), so the hook can run + // idempotently regardless of which test kicks first. Without + // this redirection, any test that installs the bitcoin-ui + // fixture would try to write under /var/lib/archipelago. + orch.set_bitcoin_ui_paths(test_bitcoin_ui_paths()); + orch + } + + fn test_bitcoin_ui_paths() -> bitcoin_ui::RenderPaths { + use std::sync::OnceLock; + static DIR: OnceLock = OnceLock::new(); + let dir = DIR.get_or_init(|| { + let d = tempfile::TempDir::new().expect("test tmpdir"); + std::fs::write(d.path().join("bitcoin-rpc-password"), "test-pass\n") + .expect("seed password"); + d + }); + bitcoin_ui::RenderPaths { + secret_path: dir.path().join("bitcoin-rpc-password"), + rendered_path: dir.path().join("nginx.conf"), + } } #[tokio::test] @@ -757,6 +861,51 @@ mod tests { assert!(calls.iter().any(|c| c == "start_container:archy-bitcoin-ui")); } + #[tokio::test] + async fn install_bitcoin_ui_renders_nginx_conf_via_hook() { + // End-to-end: install("bitcoin-ui") must invoke the pre-start + // hook, which atomic-writes a rendered nginx.conf to the + // test-scoped RenderPaths. Asserts both that the file lands + // on disk and that the `{{BITCOIN_RPC_AUTH}}` placeholder has + // been substituted with `base64("archipelago:test-pass")`. + let rt = Arc::new(MockRuntime::default()); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + build_manifest("bitcoin-ui", "/opt/archy/docker/bitcoin-ui", "archy-bitcoin-ui:local"), + PathBuf::from("/opt/archy/apps/bitcoin-ui"), + ) + .await; + + orch.install("bitcoin-ui").await.unwrap(); + + let rendered_path = test_bitcoin_ui_paths().rendered_path; + let contents = std::fs::read_to_string(&rendered_path) + .expect("hook must have written nginx.conf during install"); + // Placeholder gone. + assert!( + !contents.contains("{{BITCOIN_RPC_AUTH}}"), + "placeholder was not substituted:\n{contents}" + ); + // Deterministic auth blob for the seeded test password. + assert!( + contents.contains("Basic YXJjaGlwZWxhZ286dGVzdC1wYXNz"), + "expected base64(archipelago:test-pass) in rendered conf:\n{contents}" + ); + // Hook must fire BEFORE create_container so the bind-mount + // target exists when podman goes to attach it. + let calls = rt.calls(); + let create_idx = calls + .iter() + .position(|c| c == "create_container:archy-bitcoin-ui:offset=0") + .expect("create_container must have been called"); + // With the hook running synchronously inside install_fresh the + // rendered file must already be on disk by the time + // create_container returns — which we just asserted above. + // Keep the index lookup to make the ordering contract explicit + // for future readers. + let _ = create_idx; + } + #[tokio::test] async fn install_fresh_build_skipped_when_image_present() { let rt = Arc::new(MockRuntime::default()); diff --git a/docker/bitcoin-ui/Dockerfile b/docker/bitcoin-ui/Dockerfile index 86319f4b..42367306 100644 --- a/docker/bitcoin-ui/Dockerfile +++ b/docker/bitcoin-ui/Dockerfile @@ -1,9 +1,22 @@ FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine +# Static site content. 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 -# Run nginx as root to avoid chown failures in rootless Podman user namespaces +# +# NOTE: /etc/nginx/conf.d/default.conf is intentionally NOT copied from +# this build context. It is bind-mounted at container-create time from +# /var/lib/archipelago/bitcoin-ui/nginx.conf on the host, which the +# archipelago prod orchestrator renders with the current base64 RPC +# auth substituted in (see core/archipelago/src/container/bitcoin_ui.rs). +# +# If the bind-mount fails nginx will start with no site configured and +# return 404 on every request. That's the intended safe failure mode — +# better than baking a placeholder into the image and potentially +# serving the upstream RPC proxy with a stale/empty Authorization header. +# +# Run nginx as root to avoid chown failures in rootless Podman user +# namespaces. The rest of the nginx image is unchanged. RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \ mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \ /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \