feat(container): bitcoin-ui pre-start hook renders nginx.conf from embedded template
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:<password>), 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).
This commit is contained in:
parent
ba8bd0bb86
commit
3e9c192b48
56
apps/bitcoin-ui/manifest.yml
Normal file
56
apps/bitcoin-ui/manifest.yml
Normal file
@ -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
|
||||||
38
apps/electrs-ui/manifest.yml
Normal file
38
apps/electrs-ui/manifest.yml
Normal file
@ -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
|
||||||
40
apps/lnd-ui/manifest.yml
Normal file
40
apps/lnd-ui/manifest.yml
Normal file
@ -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
|
||||||
274
core/archipelago/src/container/bitcoin_ui.rs
Normal file
274
core/archipelago/src/container/bitcoin_ui.rs
Normal file
@ -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<RenderOutcome> {
|
||||||
|
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<String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
pub mod bitcoin_ui;
|
||||||
pub mod boot_reconciler;
|
pub mod boot_reconciler;
|
||||||
pub mod data_manager;
|
pub mod data_manager;
|
||||||
pub mod dev_orchestrator;
|
pub mod dev_orchestrator;
|
||||||
|
|||||||
@ -35,6 +35,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime};
|
use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime};
|
||||||
|
use crate::container::bitcoin_ui;
|
||||||
use crate::container::traits::ContainerOrchestrator;
|
use crate::container::traits::ContainerOrchestrator;
|
||||||
|
|
||||||
/// App IDs whose containers are named `archy-<id>` rather than bare `<id>`.
|
/// App IDs whose containers are named `archy-<id>` rather than bare `<id>`.
|
||||||
@ -127,6 +128,10 @@ pub struct ProdContainerOrchestrator {
|
|||||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||||
manifests_dir: PathBuf,
|
manifests_dir: PathBuf,
|
||||||
state: Arc<RwLock<OrchestratorState>>,
|
state: Arc<RwLock<OrchestratorState>>,
|
||||||
|
/// 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 {
|
impl ProdContainerOrchestrator {
|
||||||
@ -156,6 +161,7 @@ impl ProdContainerOrchestrator {
|
|||||||
runtime,
|
runtime,
|
||||||
manifests_dir,
|
manifests_dir,
|
||||||
state: Arc::new(RwLock::new(OrchestratorState::new())),
|
state: Arc::new(RwLock::new(OrchestratorState::new())),
|
||||||
|
bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,9 +176,18 @@ impl ProdContainerOrchestrator {
|
|||||||
runtime,
|
runtime,
|
||||||
manifests_dir,
|
manifests_dir,
|
||||||
state: Arc::new(RwLock::new(OrchestratorState::new())),
|
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,
|
/// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each,
|
||||||
/// validates it, and stores it in the in-memory state.
|
/// validates it, and stores it in the in-memory state.
|
||||||
///
|
///
|
||||||
@ -313,8 +328,29 @@ impl ProdContainerOrchestrator {
|
|||||||
let name = compute_container_name(&lm.manifest);
|
let name = compute_container_name(&lm.manifest);
|
||||||
match self.runtime.get_container_status(&name).await {
|
match self.runtime.get_container_status(&name).await {
|
||||||
Ok(status) => match status.state {
|
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 => {
|
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
|
self.runtime
|
||||||
.start_container(&name)
|
.start_container(&name)
|
||||||
.await
|
.await
|
||||||
@ -378,6 +414,12 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = compute_container_name(&lm.manifest);
|
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.
|
// Production orchestrator: no port offset.
|
||||||
self.runtime
|
self.runtime
|
||||||
.create_container(&lm.manifest, &name, 0)
|
.create_container(&lm.manifest, &name, 0)
|
||||||
@ -396,6 +438,45 @@ impl ProdContainerOrchestrator {
|
|||||||
// in the `impl ContainerOrchestrator for ProdContainerOrchestrator` block
|
// in the `impl ContainerOrchestrator for ProdContainerOrchestrator` block
|
||||||
// below — call those through the trait, not as inherent methods.
|
// 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<Option<HookOutcome>> {
|
||||||
|
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<MockRuntime>) -> ProdContainerOrchestrator {
|
async fn orch_with(runtime: Arc<MockRuntime>) -> ProdContainerOrchestrator {
|
||||||
ProdContainerOrchestrator::with_runtime(
|
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||||
runtime,
|
runtime,
|
||||||
PathBuf::from("/nonexistent-for-tests"),
|
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<tempfile::TempDir> = 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]
|
#[tokio::test]
|
||||||
@ -757,6 +861,51 @@ mod tests {
|
|||||||
assert!(calls.iter().any(|c| c == "start_container:archy-bitcoin-ui"));
|
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]
|
#[tokio::test]
|
||||||
async fn install_fresh_build_skipped_when_image_present() {
|
async fn install_fresh_build_skipped_when_image_present() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
|||||||
@ -1,9 +1,22 @@
|
|||||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
|
# Static site content.
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
COPY 50x.html /usr/share/nginx/html/
|
COPY 50x.html /usr/share/nginx/html/
|
||||||
COPY assets/ /usr/share/nginx/html/assets/
|
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 && \
|
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user