chore(release): stage v1.7.54-alpha

This commit is contained in:
archipelago 2026-05-06 09:23:57 -04:00
parent 1a0d8a432c
commit c0751e2551
30 changed files with 1871 additions and 102 deletions

View File

@ -1,5 +1,13 @@
# Changelog # Changelog
## v1.7.54-alpha (2026-05-06)
- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.
- LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.
- OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.
- Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.
- `.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana.
## v1.7.53-alpha (2026-05-05) ## v1.7.53-alpha (2026-05-05)
- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures. - Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.

View File

@ -8,6 +8,7 @@ app:
image: grafana/grafana:10.2.0 image: grafana/grafana:10.2.0
image_signature: cosign://... image_signature: cosign://...
pull_policy: if-not-present pull_policy: if-not-present
data_uid: "472:472"
dependencies: dependencies:
- storage: 5Gi - storage: 5Gi

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.53-alpha" version = "1.7.54-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.53-alpha" version = "1.7.54-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -192,7 +192,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3") ("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
} }
"grafana" => ( "grafana" => (
"curl -sf http://localhost:3000/api/health || exit 1", "test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
"30s", "30s",
"3", "3",
), ),
@ -292,7 +292,8 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"nginx-proxy-manager" => "256m", "nginx-proxy-manager" => "256m",
// Databases // Databases
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m", "archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
"immich_postgres" | "penpot-postgres" => "256m", "immich_postgres" => "2g",
"penpot-postgres" => "256m",
"immich_redis" | "penpot-valkey" => "128m", "immich_redis" | "penpot-valkey" => "128m",
// Default // Default
_ => "512m", _ => "512m",
@ -428,7 +429,7 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::all_container_names; use super::{all_container_names, get_health_check_args};
#[test] #[test]
fn bitcoin_variant_container_names_are_precise() { fn bitcoin_variant_container_names_are_precise() {
@ -440,6 +441,19 @@ mod tests {
assert!(knots.contains(&"bitcoin-knots".to_string())); assert!(knots.contains(&"bitcoin-knots".to_string()));
assert!(!knots.contains(&"bitcoin-core".to_string())); assert!(!knots.contains(&"bitcoin-core".to_string()));
} }
#[test]
fn grafana_health_requires_writable_data_and_http_health() {
let args = get_health_check_args("grafana", "unused");
let health_cmd = args
.iter()
.find_map(|arg| arg.strip_prefix("--health-cmd="))
.expect("grafana should have a health command");
assert!(health_cmd.contains("test -w /var/lib/grafana"));
assert!(health_cmd.contains("test -w /var/lib/grafana/grafana.db"));
assert!(health_cmd.contains("http://localhost:3000/api/health"));
}
} }
/// Get data directories to clean for an app. /// Get data directories to clean for an app.

View File

@ -669,6 +669,9 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
for name in to_start { for name in to_start {
ensure_runtime_host_port_listener(name).await?; ensure_runtime_host_port_listener(name).await?;
} }
if to_start.iter().any(|name| name == "indeedhub") {
super::install::patch_indeedhub_nostr_provider().await;
}
Ok(()) Ok(())
} }
@ -826,6 +829,9 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
} }
ensure_runtime_host_port_listener(name).await?; ensure_runtime_host_port_listener(name).await?;
} }
if containers.iter().any(|name| name == "indeedhub") {
super::install::patch_indeedhub_nostr_provider().await;
}
if !errors.is_empty() { if !errors.is_empty() {
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; "))); return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
} }
@ -842,7 +848,10 @@ async fn repair_before_package_start(container_name: &str) {
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await, "btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay" "indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await, | "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
"grafana" => cleanup_stale_pasta_port("3000").await, "grafana" => {
repair_grafana_dirs().await;
cleanup_stale_pasta_port("3000").await;
}
"gitea" => cleanup_gitea_stale_ports().await, "gitea" => cleanup_gitea_stale_ports().await,
_ => {} _ => {}
} }
@ -943,6 +952,34 @@ async fn repair_btcpay_dirs() {
repair_btcpay_database_password().await; repair_btcpay_database_password().await;
} }
async fn repair_grafana_dirs() {
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/grafana"])
.output()
.await;
let podman_chown = tokio::process::Command::new("podman")
.args([
"unshare",
"chown",
"-R",
"472:472",
"/var/lib/archipelago/grafana",
])
.output()
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"100471:100471",
"/var/lib/archipelago/grafana",
])
.output()
.await;
}
}
async fn repair_btcpay_database_password() { async fn repair_btcpay_database_password() {
let Ok(db_pass) = let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await

View File

@ -450,7 +450,7 @@ impl RpcHandler {
"--cap-add=SETGID", "--cap-add=SETGID",
"--cap-add=SETUID", "--cap-add=SETUID",
"--security-opt=no-new-privileges:true", "--security-opt=no-new-privileges:true",
"--memory=512m", "--memory=2g",
"--pids-limit=4096", "--pids-limit=4096",
"--health-cmd=pg_isready -U postgres || exit 1", "--health-cmd=pg_isready -U postgres || exit 1",
"--health-interval=30s", "--health-interval=30s",

View File

@ -8,8 +8,8 @@
//! //!
//! Two things are synced on startup: //! Two things are synced on startup:
//! 1. Doctor artifacts (container-doctor.sh + service + timer). //! 1. Doctor artifacts (container-doctor.sh + service + timer).
//! 2. An nginx `location /api/app-catalog` proxy block — required for //! 2. Missing nginx backend proxy blocks required for frontend fetches to
//! the App Store catalog proxy to actually reach the backend. //! reach the backend instead of the SPA fallback.
//! //!
//! Idempotent: no-ops on boxes that are already in sync. All work is //! Idempotent: no-ops on boxes that are already in sync. All work is
//! best-effort — failures are logged but never abort the backend. //! best-effort — failures are logged but never abort the backend.
@ -31,6 +31,7 @@ const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.servic
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer"; const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago"; const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime"; const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the /// Inserted into every server block of the nginx config that lacks the
@ -38,6 +39,8 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// image-recipe/configs/nginx-archipelago.conf. /// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n"; const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Entry point called from main startup. Never returns an error to the caller — /// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving. /// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() { pub async fn ensure_doctor_installed() {
@ -57,8 +60,8 @@ pub async fn ensure_doctor_installed() {
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e), Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
} }
match run_nginx().await { match run_nginx().await {
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"), Ok(true) => info!("Patched nginx config to proxy missing backend endpoints"),
Ok(false) => debug!("Nginx already has /api/app-catalog block"), Ok(false) => debug!("Nginx backend endpoint proxy blocks already present"),
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e), Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
} }
match run_bitcoin_rpc_repair().await { match run_bitcoin_rpc_repair().await {
@ -444,13 +447,10 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true) Ok(true)
} }
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if /// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// it's missing. The original ISO shipped individual per-endpoint `location` /// configs shipped individual per-endpoint `location` blocks, so missing
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell /// endpoints silently fell through to the SPA `index.html` and the frontend
/// through to the SPA `index.html` and the frontend got HTML instead of /// got HTML instead of JSON.
/// JSON. We anchor the insert to the DWN comment that already sits right
/// after the `/api/blob` block, so the new block lands in both the HTTP
/// and HTTPS server blocks.
/// ///
/// Validates via `nginx -t` before reloading. On failure the patch is /// Validates via `nginx -t` before reloading. On failure the patch is
/// rolled back from a backup written just before the write. /// rolled back from a backup written just before the write.
@ -465,51 +465,90 @@ async fn run_nginx() -> Result<bool> {
return Ok(false); return Ok(false);
} }
if !Path::new(NGINX_CONF_PATH).exists() { let mut changed = false;
debug!("{} missing — skipping nginx bootstrap", NGINX_CONF_PATH); let mut patched_paths = Vec::<PathBuf>::new();
return Ok(false); for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
let candidate = Path::new(path);
if !candidate.exists() {
debug!("{} missing — skipping nginx bootstrap", path);
continue;
}
let canonical = fs::canonicalize(candidate)
.await
.unwrap_or_else(|_| candidate.to_path_buf());
if patched_paths.iter().any(|p| p == &canonical) {
continue;
}
patched_paths.push(canonical);
changed |= patch_nginx_conf(path).await?;
} }
Ok(changed)
}
let content = fs::read_to_string(NGINX_CONF_PATH) async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await .await
.with_context(|| format!("read {}", NGINX_CONF_PATH))?; .with_context(|| format!("read {}", path))?;
if content.contains("location /api/app-catalog") { let missing_app_catalog = !content.contains("location /api/app-catalog");
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
if !missing_app_catalog && !missing_bitcoin_status {
return Ok(false); return Ok(false);
} }
// The DWN comment sits at the same indent right after the `/api/blob` let mut patched = content.clone();
// block in both server blocks — a stable anchor that existed on every
// ISO shipped to date. If it's absent (config got heavily customized), if missing_bitcoin_status {
// we bail rather than guess where to splice. let anchor = " location /electrs-status {";
let anchor = " # DWN endpoints — peer access over Tor (no auth)"; if !patched.contains(anchor) {
if !content.contains(anchor) { warn!("nginx conf missing electrs-status anchor — skipping /bitcoin-status patch");
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch"); } else {
return Ok(false); let replacement = format!("{}{}", NGINX_BITCOIN_STATUS_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
} }
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor); if missing_app_catalog {
let patched = content.replace(anchor, &replacement); // The DWN comment sits at the same indent right after the `/api/blob`
// block in both server blocks — a stable anchor that existed on every
// ISO shipped to date. If it's absent (config got heavily customized),
// skip rather than guess where to splice.
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
if !patched.contains(anchor) {
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
} else {
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
}
if patched == content {
return Ok(false);
}
// Write patched config via a user-owned tmp + sudo mv, after stashing // Write patched config via a user-owned tmp + sudo mv, after stashing
// a backup so we can revert if `nginx -t` hates what we produced. // a backup outside nginx include dirs so validation cannot load it too.
let pid = std::process::id(); let pid = std::process::id();
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid); let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
fs::write(&tmp, &patched) fs::write(&tmp, &patched)
.await .await
.with_context(|| format!("write {}", tmp))?; .with_context(|| format!("write {}", tmp))?;
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid); let backup = format!(
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await { "/tmp/archipelago-nginx-backup-{}-{}.conf",
pid,
patched.len()
);
if let Err(e) = host_sudo(&["cp", path, &backup]).await {
let _ = fs::remove_file(&tmp).await; let _ = fs::remove_file(&tmp).await;
return Err(e.context("backup nginx conf")); return Err(e.context("backup nginx conf"));
} }
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await; let mv = host_sudo(&["mv", &tmp, path]).await;
match mv { match mv {
Ok(s) if s.success() => {} Ok(s) if s.success() => {}
Ok(s) => { Ok(s) => {
let _ = fs::remove_file(&tmp).await; let _ = fs::remove_file(&tmp).await;
anyhow::bail!("sudo mv nginx conf exited with {}", s); anyhow::bail!("sudo mv nginx conf to {} exited with {}", path, s);
} }
Err(e) => { Err(e) => {
let _ = fs::remove_file(&tmp).await; let _ = fs::remove_file(&tmp).await;
@ -522,7 +561,7 @@ async fn run_nginx() -> Result<bool> {
let valid = matches!(&test, Ok(s) if s.success()); let valid = matches!(&test, Ok(s) if s.success());
if !valid { if !valid {
warn!("nginx -t failed after patch — reverting"); warn!("nginx -t failed after patch — reverting");
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await; let _ = host_sudo(&["mv", &backup, path]).await;
if let Err(e) = test { if let Err(e) = test {
return Err(e.context("nginx -t")); return Err(e.context("nginx -t"));
} }

View File

@ -186,6 +186,7 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
/// URL for pull). /// URL for pull).
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> { async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
let local_image = format!("localhost/{}:latest", spec.image_base); let local_image = format!("localhost/{}:latest", spec.image_base);
let local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base); let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
// Prefer local build — companions can carry build-time customizations // Prefer local build — companions can carry build-time customizations
@ -193,6 +194,9 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
for dir in spec.build_dir_candidates { for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile"); let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) { if fs::try_exists(&dockerfile).await.unwrap_or(false) {
if image_exists(&local_image_compat).await {
return Ok(local_image_compat);
}
if image_exists(&local_image).await { if image_exists(&local_image).await {
return Ok(local_image); return Ok(local_image);
} }
@ -335,13 +339,18 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
} }
/// Does this companion need install_one to be re-run? Returns true if /// Does this companion need install_one to be re-run? Returns true if
/// the unit file is missing OR the service is not active. /// the unit file is missing, stale, or the service is not active.
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> { async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
let dir = quadlet::unit_dir().await?; let dir = quadlet::unit_dir().await?;
let unit_path = dir.join(format!("{}.container", spec.name)); let unit_path = dir.join(format!("{}.container", spec.name));
if !fs::try_exists(&unit_path).await.unwrap_or(false) { if !fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(true); return Ok(true);
} }
let expected_image = ensure_image_present(spec).await?;
let expected_unit = build_unit(spec, &expected_image);
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
return Ok(true);
}
let svc = format!("{}.service", spec.name); let svc = format!("{}.service", spec.name);
Ok(!quadlet::is_active(&svc).await) Ok(!quadlet::is_active(&svc).await)
} }

View File

@ -113,6 +113,118 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
)) ))
} }
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
async fn patch_indeedhub_nostr_provider() {
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
}
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
if !already_patched {
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
.output()
.await;
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.output()
.await;
}
/// Outcome of `reconcile_all` for a single app. /// Outcome of `reconcile_all` for a single app.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconcileAction { pub enum ReconcileAction {
@ -501,9 +613,10 @@ impl ProdContainerOrchestrator {
let app_id = lm.manifest.app.id.clone(); let app_id = lm.manifest.app.id.clone();
if app_id == "indeedhub" { if app_id == "indeedhub" {
// IndeedHub is a multi-container stack installed by the package // IndeedHub is a multi-container stack installed by the package
// stack path. Reconciling its single manifest races stack installs // stack path. Boot reconcile must not fresh-install the catalog
// and can recreate a broken frontend container with the same name. // manifest, but it does need to start/repair an already-installed
return Ok(ReconcileAction::Left("stack-managed".to_string())); // stack and reapply the frontend's Nostr provider patch after boot.
return self.reconcile_indeedhub_stack(mode).await;
} }
let lock = self.app_lock(&app_id).await; let lock = self.app_lock(&app_id).await;
let _guard = lock.lock().await; let _guard = lock.lock().await;
@ -720,10 +833,24 @@ impl ProdContainerOrchestrator {
async fn run_post_data_uid_hooks(&self, app_id: &str) -> Result<()> { async fn run_post_data_uid_hooks(&self, app_id: &str) -> Result<()> {
match app_id { match app_id {
"fedimint" | "fedimint-gateway" => self.ensure_fedimint_dirs().await, "fedimint" | "fedimint-gateway" => self.ensure_fedimint_dirs().await,
"grafana" => self.ensure_grafana_dirs().await,
_ => Ok(()), _ => Ok(()),
} }
} }
async fn ensure_grafana_dirs(&self) -> Result<()> {
let dir = "/var/lib/archipelago/grafana";
let mkdir = host_sudo(&["mkdir", "-p", dir])
.await
.context("mkdir grafana data dir")?;
if !mkdir.success() {
return Err(anyhow::anyhow!("mkdir -p {dir} failed with status {mkdir}"));
}
chown_for_rootless_container("472:472", dir)
.await
.context("chown grafana data dir for rootless uid 472")
}
/// Phase 3.3 in-place migration. When `use_quadlet_backends` flips /// Phase 3.3 in-place migration. When `use_quadlet_backends` flips
/// from off → on, existing nodes have backend containers parented /// from off → on, existing nodes have backend containers parented
/// under archipelago.service's cgroup (the bad shape). They need to /// under archipelago.service's cgroup (the bad shape). They need to
@ -1138,6 +1265,59 @@ impl ProdContainerOrchestrator {
Ok(()) Ok(())
} }
async fn reconcile_indeedhub_stack(&self, mode: ReconcileMode) -> Result<ReconcileAction> {
let frontend_status = match self.runtime.get_container_status("indeedhub").await {
Ok(status) => status,
Err(_) => {
if mode == ReconcileMode::ExistingOnly {
return Ok(ReconcileAction::Left("absent".to_string()));
}
// Fresh stack creation is owned by package::stacks so we do not
// create a single broken frontend container from the manifest.
return Ok(ReconcileAction::Left("stack-managed".to_string()));
}
};
self.start_indeedhub_backends().await?;
let mut started = false;
match frontend_status.state {
ContainerState::Running => {}
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created => {
self.runtime
.start_container("indeedhub")
.await
.context("start IndeedHub frontend during reconcile")?;
started = true;
}
ContainerState::Paused => return Ok(ReconcileAction::Left("paused".to_string())),
ContainerState::Unknown(s) => return Ok(ReconcileAction::Left(s)),
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
self.repair_indeedhub_network_aliases().await;
patch_indeedhub_nostr_provider().await;
if !wait_for_host_port(7778, 10).await {
tracing::warn!(
"IndeedHub frontend running but host port 7778 is not listening; restarting"
);
let _ = self.runtime.stop_container("indeedhub").await;
self.runtime
.start_container("indeedhub")
.await
.context("restart IndeedHub frontend after missing host port")?;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
patch_indeedhub_nostr_provider().await;
}
if started {
Ok(ReconcileAction::Started)
} else {
Ok(ReconcileAction::NoOp)
}
}
async fn repair_indeedhub_network_aliases(&self) { async fn repair_indeedhub_network_aliases(&self) {
for (container, alias) in [ for (container, alias) in [
("indeedhub-postgres", "postgres"), ("indeedhub-postgres", "postgres"),
@ -1302,6 +1482,10 @@ impl ProdContainerOrchestrator {
return false; return false;
} }
if self.container_command_drifted(name, manifest).await {
return true;
}
let inspect = tokio::process::Command::new("podman") let inspect = tokio::process::Command::new("podman")
.args([ .args([
"inspect", "inspect",
@ -1334,6 +1518,52 @@ impl ProdContainerOrchestrator {
}) })
} }
async fn container_command_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if manifest.app.container.entrypoint.is_none()
&& manifest.app.container.custom_args.is_empty()
{
return false;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"entry={{json .Config.Entrypoint}}\ncmd={{json .Config.Cmd}}",
])
.output()
.await;
let Ok(output) = inspect else {
return false;
};
if !output.status.success() {
return false;
}
let text = String::from_utf8_lossy(&output.stdout);
let current_entry = text
.lines()
.find_map(|line| line.strip_prefix("entry="))
.and_then(|json| serde_json::from_str::<Option<Vec<String>>>(json).ok())
.flatten()
.unwrap_or_default();
let current_cmd = text
.lines()
.find_map(|line| line.strip_prefix("cmd="))
.and_then(|json| serde_json::from_str::<Option<Vec<String>>>(json).ok())
.flatten()
.unwrap_or_default();
let expected_entry = manifest
.app
.container
.entrypoint
.clone()
.unwrap_or_default();
current_entry != expected_entry || current_cmd != manifest.app.container.custom_args
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> { async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else { let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
return Ok(()); return Ok(());

View File

@ -829,6 +829,41 @@ app:
assert_eq!(u.restart_policy, RestartPolicy::OnFailure); assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
} }
#[test]
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
let yaml = r#"
app:
id: grafana
name: Grafana
version: 10.2.0
container:
image: grafana/grafana:10.2.0
data_uid: "472:472"
volumes:
- type: bind
source: /var/lib/archipelago/grafana
target: /var/lib/grafana
options: [rw]
resources:
memory_limit: 1g
"#;
let m = AppManifest::parse(yaml).unwrap();
assert_eq!(m.app.container.data_uid.as_deref(), Some("472:472"));
let u = QuadletUnit::from_manifest(&m, "grafana");
assert_eq!(u.memory_mb, Some(1024));
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(
u.bind_mounts[0].host,
PathBuf::from("/var/lib/archipelago/grafana")
);
assert_eq!(
u.bind_mounts[0].container,
PathBuf::from("/var/lib/grafana")
);
assert!(!u.bind_mounts[0].read_only);
}
#[test] #[test]
fn from_manifest_marks_ro_volumes_read_only() { fn from_manifest_marks_ro_volumes_read_only() {
let yaml = r#" let yaml = r#"

View File

@ -0,0 +1,317 @@
# Chat Transcript And Working Notes
Date: 2026-05-02
This file captures the current chat context, decisions, progress, and next steps so work can continue from another device/session.
## User Request
The user asked to continue hardening Archipelago app/container lifecycle, then asked multiple times to save the plan/progress/next steps and finally to save the entire chat to Markdown.
Key user constraints and corrections:
- Continue if next steps are clear; ask only if blocked.
- Exhaustively harden app/container lifecycle before release.
- Preserve data during destructive lifecycle testing unless explicitly instructed otherwise.
- Do not rely on `/app/...` proxy paths for app launch/testing. The user corrected: “we never use paths only ports.”
- LND/Electrum wallet-connect tests must validate real connection details and QR, including Tor.
## Earlier Progress Summary
Before the latest work, the project already had substantial lifecycle hardening in progress:
- Remote lifecycle harness exists at `tests/lifecycle/remote-lifecycle.sh`.
- `.198` SSH works with `/home/archipelago/.ssh/id_ed25519`.
- `.228` RPC works, but SSH is blocked with `Permission denied (publickey,password)`.
- Multiple backend release binaries were built and deployed to `.198` with backups in `/usr/local/bin/archipelago.bak-*`.
- Fixed stale package scanner state recovery from `Removing -> Running` when a container is actually live.
- Fixed startup ordering so crash recovery runs before BootReconciler.
- Removed dangerous automatic Podman runtime directory deletion on `podman info` failure.
- Narrowed generic crash recovery to safe legacy containers.
- Fixed companion reconciliation on install/start/restart.
- Fixed uninstall/reinstall behavior so uninstall disables manifest apps instead of deleting manifest availability, and reinstall re-enables them.
- Fixed LND config generation/repair:
- `bitcoin.active=true`
- `bitcoin.mainnet=true`
- `bitcoin.node=bitcoind`
- `bitcoind.rpchost=bitcoin-knots:8332`
- sudo fallback for writing container-owned config paths.
- `.198` had previously passed focused lifecycle for `filebrowser`, `bitcoin-knots`, and a looser LND launch test.
## Major Files Touched In This Session
- `docs/CONTAINER_LIFECYCLE_HANDOFF.md`
- `docs/CHAT_TRANSCRIPT_2026-05-02.md`
- `tests/lifecycle/remote-lifecycle.sh`
- `core/archipelago/src/container/lnd.rs`
- `core/archipelago/src/container/companion.rs`
- `core/archipelago/src/container/prod_orchestrator.rs`
- `core/archipelago/src/container/docker_packages.rs`
- `core/container/src/podman_client.rs`
- `core/archipelago/src/port_allocator.rs`
- `apps/lnd-ui/manifest.yml`
- `neode-ui/src/views/appSession/appSessionConfig.ts`
- `neode-ui/src/stores/container.ts`
- `neode-ui/src/stores/appLauncher.ts`
- `neode-ui/src/views/appDetails/appDetailsData.ts`
- nginx config/snippet files under `scripts/` and `image-recipe/`
## LND Wallet Bootstrap Investigation
Initial strict LND probe failed because `/lnd-connect-info` could not read `admin.macaroon`:
```text
Failed to read LND admin macaroon — is LND installed?
direct: Permission denied (os error 13)
sudo: cat: /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon: No such file or directory
```
LND logs showed the wallet was uninitialized/locked:
```text
Waiting for wallet encryption password. Use lncli create...
```
Tests showed `lncli create` is interactive and does not support `--stdin`:
```text
[lncli] flag provided but not defined: -stdin
```
`lncli unlock --stdin` is supported, so the final approach was:
- Use LND REST unlocker endpoints for new wallet creation.
- Use `lncli unlock --stdin` only for an existing wallet.
- Treat “wallet already exists” from REST as a signal to unlock.
- Use sudo-aware checks/reads for wallet artifacts because LND data directories are container-owned and `0700`.
Implemented in `core/archipelago/src/container/lnd.rs`:
- `ensure_wallet_initialized()`
- `file_exists_as_root()`
- `read_file_as_root()`
- `init_wallet_via_rest()`
- `get_lnd_unlocker_json()`
- `post_lnd_unlocker_json()`
- `unlock_existing_wallet()`
- `wait_for_admin_macaroon()`
- `lnd_getinfo_ready()`
Focused Rust test passes:
```bash
cd /home/archipelago/Projects/archy/core
cargo test -p archipelago --bin archipelago lnd
```
Result:
```text
7 passed; 0 failed
```
## LND UI Port Collision
The strict LND UI test then failed with `502`.
Investigation found a real port collision:
- `nostr-rs-relay` uses host `8081`.
- Old `archy-lnd-ui` also used host `8081`.
- nginx `/app/lnd/` proxy also pointed at `8081`.
Fix implemented:
- Move LND UI companion to host port `18083`, container port `80`.
- Keep `nostr-rs-relay` on `8081`.
- Update app metadata/routing to `18083`.
- Update tests to expect direct port launch.
Important correction from user:
```text
we never use paths only ports, how many times do you need to be told
```
Action taken after correction:
- Stop validating through `/app/lnd/` and `/app/electrumx/` in the lifecycle harness.
- Switch `launch_url_for()` to direct app ports.
- Switch app session resolver to direct `http://host:port` launch, even from HTTPS parent pages.
- Remove use of `HTTPS_PROXY_PATHS[id]` in `resolveAppUrl()`.
Direct-port LND audit command:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=lnd tests/lifecycle/remote-lifecycle.sh
```
Result:
```text
### 192.168.1.198 iteration 1 / 1 ###
lnd state=running
all checks passed
```
The audit now validates `http://192.168.1.198:18083/`, not `/app/lnd/`.
## Lifecycle Harness Changes
`tests/lifecycle/remote-lifecycle.sh` changes made:
- Normalize package states with `ascii_downcase` because API returned `Running`.
- Direct port launch URLs:
- LND: `http://${ARCHY_HOST}:18083/`
- Electrum/Electrs: `http://${ARCHY_HOST}:50002/`
- Bitcoin UI: `http://${ARCHY_HOST}:8334/`
- Other apps mapped to direct ports where known.
- LND probe checks:
- `Connect Your Wallet`
- `id="lndQrBox"`
- `id="connHost"`
- `value="rest-tor"`
- `value="grpc-tor"`
- `value="rest-local"`
- `value="grpc-local"`
- `Copy lndconnect URI`
- `/lnd-connect-info` cert, macaroon, ports, and Tor onion.
- Electrum probe checks:
- local QR container and address field
- Tor QR container and onion field
- port `50001`
- QR renderer
- direct `http://${ARCHY_HOST}:50002/qrcode.js`
- `/electrs-status` Tor onion.
- Full lifecycle now fails immediately on any failed phase with `|| return 1` so a later reinstall cannot mask a failed restart/probe.
## Deployments To `.198`
Several release builds were made and deployed:
```bash
cd /home/archipelago/Projects/archy/core
cargo build -p archipelago --bin archipelago --release
```
Deploy pattern:
```bash
scp -i /home/archipelago/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
/home/archipelago/Projects/archy/core/target/release/archipelago \
archipelago@192.168.1.198:/tmp/archipelago.new
ssh -i /home/archipelago/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
archipelago@192.168.1.198 \
"sudo cp /usr/local/bin/archipelago /usr/local/bin/archipelago.bak-<timestamp> && \
sudo install -m 0755 /tmp/archipelago.new /usr/local/bin/archipelago && \
sudo systemctl restart archipelago.service && \
systemctl is-active archipelago.service"
```
Latest deploy returned:
```text
active
```
## `.198` Current Observations
After forcing LND package restart, companion reconciliation succeeded:
```text
nostr-rs-relay Up ... 0.0.0.0:8081->8080/tcp
lnd Up ... 0.0.0.0:8080->8080/tcp, 0.0.0.0:9735->9735/tcp, 0.0.0.0:10009->10009/tcp
archy-lnd-ui Up ... 0.0.0.0:18083->80/tcp
```
Direct UI test from `.198` returned `200`:
```bash
curl -i http://127.0.0.1:18083/
```
Strict direct-port LND audit is green:
```text
lnd state=running
all checks passed
```
## Full LND Lifecycle Status
Full direct-port lifecycle was started:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=lnd ARCHY_FULL_LIFECYCLE=1 tests/lifecycle/remote-lifecycle.sh
```
It reached:
```text
### 192.168.1.198 iteration 1 / 1 ###
== lnd: install ==
== lnd: stop ==
```
Then the user aborted the command while asking to save memory/transcript.
The next continuation point is to rerun full LND direct-port lifecycle from scratch and inspect the stop phase if it hangs/fails.
## Handoff File
A durable handoff file was also created:
```text
docs/CONTAINER_LIFECYCLE_HANDOFF.md
```
It contains the plan, progress, current blockers, and next steps.
## Immediate Next Steps
1. Rerun full strict LND direct-port lifecycle:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=lnd ARCHY_FULL_LIFECYCLE=1 tests/lifecycle/remote-lifecycle.sh
```
2. If it hangs/fails at `stop`, inspect package runtime stop path and logs:
```bash
ssh -i /home/archipelago/.ssh/id_ed25519 -o StrictHostKeyChecking=no archipelago@192.168.1.198 \
'journalctl -u archipelago.service -n 260 --no-pager | egrep -i "package\.(stop|start|restart|install|uninstall)|lnd|companion|error|failed" | sed -n "1,220p"; podman ps -a --format "{{.Names}} {{.Status}} {{.Ports}}" | egrep "lnd|nostr" || true'
```
3. If stop is unreliable, inspect/fix:
- `core/archipelago/src/api/rpc/package/runtime.rs`
- `core/archipelago/src/container/prod_orchestrator.rs`
Likely causes to check:
- Reconciler restarting LND while stop is expected.
- State scanner reporting stale `running`.
- Companion handling interfering with parent app state.
- Async lifecycle returning before actual stop completes.
4. Once LND full lifecycle is green, run Electrum strict lifecycle with direct port `50002`:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=electrumx ARCHY_FULL_LIFECYCLE=1 tests/lifecycle/remote-lifecycle.sh
```
5. Continue with app groups after LND/Electrum:
- `filebrowser`
- `bitcoin-knots`
- `lnd`
- `electrumx`
- `mempool`
- `btcpay-server`
- `fedimint`
- remaining catalog apps.
## Important Instruction To Preserve
Use ports only for app launch/testing. Do not add or rely on `/app/...` path proxy launch behavior unless the user explicitly changes this requirement.

File diff suppressed because it is too large Load Diff

View File

@ -211,10 +211,15 @@ check_tools() {
fi fi
fi fi
# Ensure insecure registry config for Archipelago app registry (HTTP) # Ensure insecure registry config for Archipelago app registries that are
if [ "$CONTAINER_CMD" = "podman" ]; then # intentionally served over HTTP during ISO builds.
if [[ "$CONTAINER_CMD" == podman* ]]; then
mkdir -p /etc/containers/registries.conf.d mkdir -p /etc/containers/registries.conf.d
cat > /etc/containers/registries.conf.d/archipelago.conf <<'REGCONF' cat > /etc/containers/registries.conf.d/archipelago.conf <<'REGCONF'
[[registry]]
location = "146.59.87.168:3000"
insecure = true
[[registry]] [[registry]]
location = "git.tx1138.com" location = "git.tx1138.com"
insecure = true insecure = true
@ -227,6 +232,15 @@ check_tools
mkdir -p "$WORK_DIR" mkdir -p "$WORK_DIR"
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
container_pull() {
local image="$1"
if [[ "$CONTAINER_CMD" == podman* && "$image" == 146.59.87.168:3000/* ]]; then
$CONTAINER_CMD pull --tls-verify=false --platform "$CONTAINER_PLATFORM" "$image"
else
$CONTAINER_CMD pull --platform "$CONTAINER_PLATFORM" "$image"
fi
}
# ============================================================================= # =============================================================================
# STEP 1: Build complete root filesystem using Docker # STEP 1: Build complete root filesystem using Docker
# ============================================================================= # =============================================================================
@ -1289,7 +1303,7 @@ if [ "$UNBUNDLED" = "1" ]; then
echo " ✅ Using cached: $CORE_FILE" echo " ✅ Using cached: $CORE_FILE"
else else
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..." echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
if $CONTAINER_CMD pull --platform $CONTAINER_PLATFORM "$CORE_IMAGE"; then if container_pull "$CORE_IMAGE"; then
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \ $CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \ echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
echo " ⚠️ Failed to save $CORE_IMAGE" echo " ⚠️ Failed to save $CORE_IMAGE"
@ -1367,7 +1381,7 @@ echo "$CONTAINER_IMAGES" | while read -r image filename; do
echo " ✅ Using cached: $filename" echo " ✅ Using cached: $filename"
else else
echo " Pulling $image ($CONTAINER_PLATFORM)..." echo " Pulling $image ($CONTAINER_PLATFORM)..."
if $CONTAINER_CMD pull --platform $CONTAINER_PLATFORM "$image"; then if container_pull "$image"; then
echo " Saving $filename..." echo " Saving $filename..."
if $CONTAINER_CMD save "$image" -o "$tarpath" 2>/dev/null; then if $CONTAINER_CMD save "$image" -o "$tarpath" 2>/dev/null; then
echo " ✅ Saved: $(du -h "$tarpath" | cut -f1)" echo " ✅ Saved: $(du -h "$tarpath" | cut -f1)"
@ -3456,9 +3470,9 @@ echo ""
echo "Step 6: Creating bootable ISO..." echo "Step 6: Creating bootable ISO..."
if [ "$UNBUNDLED" = "1" ]; then if [ "$UNBUNDLED" = "1" ]; then
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-unbundled-${ARCH}.iso" OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}.iso"
else else
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${ARCH}.iso" OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}.iso"
fi fi
# Use the proven MBR code for hybrid USB boot # Use the proven MBR code for hybrid USB boot

View File

@ -156,6 +156,16 @@ server {
error_page 502 503 = @backend_unavailable; error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout; error_page 504 = @backend_timeout;
} }
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 10s;
proxy_read_timeout 10s;
proxy_send_timeout 5s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /electrs-status { location /electrs-status {
proxy_pass http://127.0.0.1:5678/electrs-status; proxy_pass http://127.0.0.1:5678/electrs-status;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -969,6 +979,16 @@ server {
error_page 502 503 = @backend_unavailable; error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout; error_page 504 = @backend_timeout;
} }
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 10s;
proxy_read_timeout 10s;
proxy_send_timeout 5s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /electrs-status { location /electrs-status {
proxy_pass http://127.0.0.1:5678/electrs-status; proxy_pass http://127.0.0.1:5678/electrs-status;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.53-alpha", "version": "1.7.54-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.53-alpha", "version": "1.7.54-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.53-alpha", "version": "1.7.54-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",

View File

@ -14,7 +14,7 @@ export const useAppStore = defineStore('app', () => {
// Writable refs — delegate reads and writes to the sub-stores // Writable refs — delegate reads and writes to the sub-stores
const { isAuthenticated, isLoading, error } = storeToRefs(auth) const { isAuthenticated, isLoading, error } = storeToRefs(auth)
const { data, isConnected, isReconnecting } = storeToRefs(sync) const { data, isConnected, isReconnecting, hasLoadedInitialData } = storeToRefs(sync)
// Read-only computed — delegate to sub-stores // Read-only computed — delegate to sub-stores
const { serverInfo, packages, peerHealth, uiData } = storeToRefs(sync) const { serverInfo, packages, peerHealth, uiData } = storeToRefs(sync)
@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
data, data,
isConnected, isConnected,
isReconnecting, isReconnecting,
hasLoadedInitialData,
// Sync computed (read-only) // Sync computed (read-only)
serverInfo, serverInfo,

View File

@ -11,6 +11,7 @@ export const useSyncStore = defineStore('sync', () => {
const data = ref<DataModel | null>(null) const data = ref<DataModel | null>(null)
const isConnected = ref(false) const isConnected = ref(false)
const isReconnecting = ref(false) const isReconnecting = ref(false)
const hasLoadedInitialData = ref(false)
let isWsSubscribed = false let isWsSubscribed = false
let isWsConnecting = false let isWsConnecting = false
@ -47,12 +48,14 @@ export const useSyncStore = defineStore('sync', () => {
if (update?.type === 'initial' && update?.data) { if (update?.type === 'initial' && update?.data) {
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend') if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
data.value = update.data data.value = update.data
hasLoadedInitialData.value = true
isConnected.value = true isConnected.value = true
isReconnecting.value = false isReconnecting.value = false
} }
// Handle real backend format: {rev: 0, data: {...}} // Handle real backend format: {rev: 0, data: {...}}
else if (update?.data && update?.rev !== undefined) { else if (update?.data && update?.rev !== undefined) {
data.value = update.data data.value = update.data
hasLoadedInitialData.value = true
isConnected.value = true isConnected.value = true
isReconnecting.value = false isReconnecting.value = false
} }
@ -90,6 +93,7 @@ export const useSyncStore = defineStore('sync', () => {
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' }) const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
if (freshState?.data) { if (freshState?.data) {
data.value = freshState.data data.value = freshState.data
hasLoadedInitialData.value = true
} }
} catch { } catch {
// Non-fatal: WebSocket patches will still work // Non-fatal: WebSocket patches will still work
@ -149,11 +153,13 @@ export const useSyncStore = defineStore('sync', () => {
theme: 'dark', theme: 'dark',
}, },
} }
hasLoadedInitialData.value = false
} }
/** Reset sync state on logout — called by auth store */ /** Reset sync state on logout — called by auth store */
function resetOnLogout(): void { function resetOnLogout(): void {
data.value = null data.value = null
hasLoadedInitialData.value = false
isWsSubscribed = false isWsSubscribed = false
wsClient.disconnect() wsClient.disconnect()
isConnected.value = false isConnected.value = false
@ -165,6 +171,7 @@ export const useSyncStore = defineStore('sync', () => {
data, data,
isConnected, isConnected,
isReconnecting, isReconnecting,
hasLoadedInitialData,
// Computed // Computed
serverInfo, serverInfo,

View File

@ -267,8 +267,7 @@ const canLaunch = computed(() => {
if (!pkg.value) return false if (!pkg.value) return false
if (isWebOnly.value) return true if (isWebOnly.value) return true
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main) const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main)
const isRunning = pkg.value.state === 'running' return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
return hasUI && isRunning
}) })
const features = computed(() => [ const features = computed(() => [

View File

@ -40,19 +40,14 @@
</div> </div>
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6"> <div v-if="isLoadingApps" class="text-center py-16 pb-6">
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse"> <div class="glass-card p-8 max-w-md mx-auto">
<div class="flex items-start gap-4"> <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
<div class="w-16 h-16 rounded-lg bg-white/10"></div> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<div class="flex-1"> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<div class="h-5 w-32 bg-white/10 rounded mb-2"></div> </svg>
<div class="h-4 w-48 bg-white/5 rounded mb-3"></div> <h3 class="text-lg font-semibold text-white mb-2">Loading apps</h3>
<div class="h-6 w-20 bg-white/5 rounded"></div> <p class="text-white/60 text-sm">Checking the latest app status before showing launch controls.</p>
</div>
</div>
<div class="mt-4 flex gap-2">
<div class="flex-1 h-9 bg-white/5 rounded-lg"></div>
</div>
</div> </div>
</div> </div>
@ -222,6 +217,8 @@ const packages = computed(() => {
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES) const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
// Connection error state // Connection error state
const connectionError = ref('') const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined let connectionTimer: ReturnType<typeof setTimeout> | undefined
@ -230,7 +227,7 @@ onMounted(() => {
appsAnimationDone = true appsAnimationDone = true
if (!store.isConnected) { if (!store.isConnected) {
connectionTimer = setTimeout(() => { connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) { if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.' connectionError.value = 'Unable to connect to server. Check that the backend is running.'
} }
}, 15000) }, 15000)

View File

@ -34,7 +34,7 @@
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template> <template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template> <template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
</p> </p>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center justify-center gap-3">
<button <button
v-if="!mustOpenNewTab" v-if="!mustOpenNewTab"
@click="$emit('refresh')" @click="$emit('refresh')"

View File

@ -145,8 +145,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean { export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true if (isWebOnlyApp(pkg.manifest.id)) return true
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting' return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
return !!hasUI && canLaunchState
} }
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string { export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {

View File

@ -1,27 +1,29 @@
{ {
"version": "1.7.53-alpha", "version": "1.7.54-alpha",
"release_date": "2026-05-05", "release_date": "2026-05-06",
"changelog": [ "changelog": [
"Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.", "Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.",
"Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners.", "LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.",
"Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror." "OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.",
"Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.",
"`.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.53-alpha", "current_version": "1.7.54-alpha",
"new_version": "1.7.53-alpha", "new_version": "1.7.54-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.53-alpha/archipelago", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.54-alpha/archipelago",
"sha256": "86cf408ed84c7a7a72d1b5529aa97561dd02db38aab57c523999d1f5e7bf48b7", "sha256": "77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632",
"size_bytes": 42352112 "size_bytes": 42600560
}, },
{ {
"name": "archipelago-frontend-1.7.53-alpha.tar.gz", "name": "archipelago-frontend-1.7.54-alpha.tar.gz",
"current_version": "1.7.53-alpha", "current_version": "1.7.54-alpha",
"new_version": "1.7.53-alpha", "new_version": "1.7.54-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.53-alpha/archipelago-frontend-1.7.53-alpha.tar.gz", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.54-alpha/archipelago-frontend-1.7.54-alpha.tar.gz",
"sha256": "87590acd32cb79866d39d87f37c7a91d85774d06aa318352b24d2b2177ccac31", "sha256": "a010ac43a2dd02f528202cb2f7b99b61ceab80adc6827877594e41df4ea951fb",
"size_bytes": 166460672 "size_bytes": 166461921
} }
] ]
} }

View File

@ -551,7 +551,7 @@ load_spec_archy-lnd-ui() {
reset_spec reset_spec
SPEC_NAME="archy-lnd-ui" SPEC_NAME="archy-lnd-ui"
SPEC_IMAGE="localhost/lnd-ui:local" SPEC_IMAGE="localhost/lnd-ui:local"
SPEC_PORTS="8081:80" SPEC_PORTS="18083:80"
SPEC_MEMORY="$(mem_limit archy-lnd-ui)" SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
SPEC_TIER="4" SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true" SPEC_LOCAL_IMAGE="true"

View File

@ -109,8 +109,15 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
fi fi
done done
fi fi
if [ -f "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" ]; then
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
echo " Including runtime nginx-archipelago.conf"
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
fi
rm -rf "$RUNTIME_DIR/scripts/resilience/reports" rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '._*' -o -name '*.log' \) -delete find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
# Force world-readable perms on every entry BEFORE tar, so the # Force world-readable perms on every entry BEFORE tar, so the
# archive's internal mode bits are 755/644 regardless of what # archive's internal mode bits are 755/644 regardless of what
# the staging dir's umask gave us. Without this, mktemp -d # the staging dir's umask gave us. Without this, mktemp -d

View File

@ -944,7 +944,7 @@ LNDCONF
fi fi
case \$ui in case \$ui in
bitcoin-ui) PORT_ARG=''; NET_ARG='--network host' ;; bitcoin-ui) PORT_ARG=''; NET_ARG='--network host' ;;
lnd-ui) PORT_ARG='-p 8081:80'; NET_ARG='' ;; lnd-ui) PORT_ARG='-p 18083:80'; NET_ARG='' ;;
electrs-ui) PORT_ARG=''; NET_ARG='--network host' ;; electrs-ui) PORT_ARG=''; NET_ARG='--network host' ;;
esac esac
if [ -d \"$TARGET_DIR/docker/\$ui\" ]; then if [ -d \"$TARGET_DIR/docker/\$ui\" ]; then

View File

@ -1003,14 +1003,14 @@ BUILDINFO_EOF
if false; then # Legacy app installation removed — kept for reference in git history if false; then # Legacy app installation removed — kept for reference in git history
progress "Rebuilding LND UI" progress "Rebuilding LND UI"
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t lnd-ui:local . || docker build --no-cache -t lnd-ui:local .)" 2>&1 | tail -12 | sed 's/^/ /'; then if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t lnd-ui:local . || docker build --no-cache -t lnd-ui:local .)" 2>&1 | tail -12 | sed 's/^/ /'; then
echo " Recreating LND UI container (port 8081)..." echo " Recreating LND UI container (port 18083)..."
ssh $SSH_OPTS "$TARGET_HOST" ' ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker command -v podman >/dev/null 2>&1 || DOCKER=docker
for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do
[ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null [ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null
done done
$DOCKER run -d --name archy-lnd-ui -p 8081:80 --memory=256m --restart unless-stopped lnd-ui:local $DOCKER run -d --name archy-lnd-ui -p 18083:80 --memory=256m --restart unless-stopped lnd-ui:local
' 2>&1 | sed 's/^/ /' || true ' 2>&1 | sed 's/^/ /' || true
fi fi

14
scripts/first-boot-containers.sh Normal file → Executable file
View File

@ -464,8 +464,8 @@ done
for dir in mempool mysql-mempool; do for dir in mempool mysql-mempool; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null [ -d "/var/lib/archipelago/$dir" ] && chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null
done done
# Grafana: container UID 472 → host UID 100472 # Grafana: chown inside podman's user namespace so container UID 472 can write SQLite.
[ -d /var/lib/archipelago/grafana ] && chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null [ -d /var/lib/archipelago/grafana ] && podman unshare chown -R 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
log "UID mapping done" log "UID mapping done"
# ── Memory limits per container ────────────────────────────────────────── # ── Memory limits per container ──────────────────────────────────────────
@ -995,9 +995,9 @@ track_container "homeassistant"
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q grafana; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q grafana; then
log "Creating Grafana..." log "Creating Grafana..."
mkdir -p /var/lib/archipelago/grafana mkdir -p /var/lib/archipelago/grafana
chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true podman unshare chown -R 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
$DOCKER run -d --name grafana --restart unless-stopped \ $DOCKER run -d --name grafana --restart unless-stopped \
--health-cmd="curl -sf http://localhost:3000/api/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \ --health-cmd="test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit grafana) \ --memory=$(mem_limit grafana) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
@ -1247,9 +1247,9 @@ for ui in bitcoin-ui lnd-ui electrs-ui; do
fi fi
case $ui in case $ui in
# UI containers use --network host so they can proxy to localhost services # UI containers use --network host so they can proxy to localhost services
# Internal nginx ports: bitcoin-ui=8334, electrs-ui=50002, lnd-ui=80 (host 8081) # Internal nginx ports: bitcoin-ui=8334, electrs-ui=50002, lnd-ui=80 (host 18083)
bitcoin-ui) PORT_ARG=""; NET_ARG="--network host"; REG_IMG="${BITCOIN_UI_IMAGE}" ;; bitcoin-ui) PORT_ARG=""; NET_ARG="--network host"; REG_IMG="${BITCOIN_UI_IMAGE}" ;;
lnd-ui) PORT_ARG="-p 8081:80"; NET_ARG=""; REG_IMG="${LND_UI_IMAGE}" ;; lnd-ui) PORT_ARG="-p 18083:80"; NET_ARG=""; REG_IMG="${LND_UI_IMAGE}" ;;
electrs-ui) PORT_ARG=""; NET_ARG="--network host"; REG_IMG="${ELECTRS_UI_IMAGE}" ;; electrs-ui) PORT_ARG=""; NET_ARG="--network host"; REG_IMG="${ELECTRS_UI_IMAGE}" ;;
esac esac
CONTAINER_NAME="archy-$ui" CONTAINER_NAME="archy-$ui"
@ -1284,7 +1284,7 @@ if [ ! -f "$SERVICES_JSON" ]; then
cat > "$SERVICES_JSON" <<'SJSON' cat > "$SERVICES_JSON" <<'SJSON'
{"services":[ {"services":[
{"name":"archipelago","local_port":80,"enabled":true}, {"name":"archipelago","local_port":80,"enabled":true},
{"name":"lnd","local_port":8081,"enabled":true}, {"name":"lnd","local_port":18083,"enabled":true},
{"name":"btcpay","local_port":23000,"enabled":true}, {"name":"btcpay","local_port":23000,"enabled":true},
{"name":"mempool","local_port":4080,"enabled":true}, {"name":"mempool","local_port":4080,"enabled":true},
{"name":"fedimint","local_port":8175,"enabled":true} {"name":"fedimint","local_port":8175,"enabled":true}

View File

@ -7,7 +7,7 @@ Each service gets its own .onion address. Tor runs in a container with host netw
| Service | LAN Port | Tor Hidden Service Dir | | Service | LAN Port | Tor Hidden Service Dir |
|-----------|----------|-------------------------------| |-----------|----------|-------------------------------|
| Archipelago | 80 | hidden_service_archipelago | | Archipelago | 80 | hidden_service_archipelago |
| LND UI | 8081 | hidden_service_lnd | | LND UI | 18083 | hidden_service_lnd |
| BTCPay | 23000 | hidden_service_btcpay | | BTCPay | 23000 | hidden_service_btcpay |
| Mempool | 4080 | hidden_service_mempool | | Mempool | 4080 | hidden_service_mempool |
| Fedimint | 8175 | hidden_service_fedimint | | Fedimint | 8175 | hidden_service_fedimint |