From a7048f6d8e2da9ad7adf3501e0f5074b22be2645 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 22 Apr 2026 08:29:56 -0400 Subject: [PATCH] release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh and image-recipe/configs/archipelago-doctor.{service,timer} via include_str! and sync to disk + enable the timer on every archipelago startup. Idempotent (content-hash compare), dev-box symlink guard keeps the git checkout untouched, best-effort (warn-only on failure) so bootstrap never blocks server readiness. Wired in main.rs as a background tokio task. - scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects when the rootless-netns has lost its pasta tap (container-to-container still works but outbound DNS/TCP fails) via an nsenter probe into aardvark-dns; with a two-probe 10s debounce to rule out transients and a host-precheck that bails out if the host itself is offline. When the rootless-netns is truly broken, does a graceful podman stop --all / start --all so pasta + aardvark-dns rebuild the netns from scratch. Bitcoin-knots and every other outbound container recover in one cycle. - core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs can reuse the existing systemd-run escape hatch. - apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned with the real container-specs.sh large-disk tune (4 GiB memory cap, cpu_limit: 0 so bitcoind can run -par=auto across every core). - neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button + Updating spinner to every app card that has available-update set. Wires through serverStore.updatePackage(id) — the same RPC the detail view already calls. common.update / common.updating i18n keys added in en.json and es.json. - core/archipelago/src/identity_manager.rs: add create_from_signing_key() that mirrors an existing Ed25519 key as a manager-level identity with a deterministic id (`node-`). Idempotent across restarts, gets the hex-SVG master avatar. - core/archipelago/src/server.rs: the auto-create path on first boot now mirrors the node's own signing_key (seed-derived on onboarded installs) as a "Node" identity instead of generating a random "Default" keypair. Once this ships, the DID on the Web5 DID Status card (via node.did RPC), the Node entry on the Identities page (via identity.list), and the DID used for peer-to-peer connects (via server_info.pubkey) all resolve to the same seed-derived pubkey. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/bitcoin-core/manifest.yml | 10 +- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/bootstrap.rs | 152 +++++++++++++++++++++++ core/archipelago/src/identity_manager.rs | 74 +++++++++++ core/archipelago/src/main.rs | 6 + core/archipelago/src/server.rs | 18 ++- core/archipelago/src/update.rs | 2 +- neode-ui/package.json | 2 +- neode-ui/src/locales/en.json | 2 + neode-ui/src/locales/es.json | 2 + neode-ui/src/views/Apps.vue | 9 ++ neode-ui/src/views/apps/AppCard.vue | 24 ++++ scripts/container-doctor.sh | 68 +++++++++- 14 files changed, 359 insertions(+), 14 deletions(-) create mode 100644 core/archipelago/src/bootstrap.rs diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 82718f9d..9e473c9b 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -1,11 +1,11 @@ app: id: bitcoin-core name: Bitcoin Core - version: 24.0.0 + version: 28.4.0 description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol. - + container: - image: bitcoin/bitcoin:24.0 + image: bitcoin/bitcoin:28.4 image_signature: cosign://... pull_policy: verify-signature @@ -13,8 +13,8 @@ app: - storage: 500Gi # Minimum disk space for mainnet resources: - cpu_limit: 2 - memory_limit: 2Gi + cpu_limit: 0 # 0 = unlimited; bitcoind uses -par=auto across all cores + memory_limit: 4Gi # matches container-specs.sh bitcoin-knots large-disk dbcache=4096 disk_limit: 500Gi security: diff --git a/core/Cargo.lock b/core/Cargo.lock index 0ddd2491..c6f934ad 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.34-alpha" +version = "1.7.35-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 0ddd5b2a..7ba26acb 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.34-alpha" +version = "1.7.35-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs new file mode 100644 index 00000000..dfdbbe7c --- /dev/null +++ b/core/archipelago/src/bootstrap.rs @@ -0,0 +1,152 @@ +//! Bootstrap host-side doctor artifacts on every archipelago startup. +//! +//! The update pipeline swaps the archipelago binary but does not touch +//! scripts or systemd units — those are installed once by the ISO builder. +//! Without this module, changes to `container-doctor.sh` or the doctor +//! service/timer never reach boxes installed before the change. +//! +//! On startup we compare three embedded files against their on-disk +//! copies and rewrite any that differ, then enable the doctor timer if +//! it isn't already. Idempotent: no-ops on boxes that match the +//! embedded version. All work is best-effort — failures are logged but +//! never abort the backend. + +use anyhow::{Context, Result}; +use std::path::Path; +use tokio::fs; +use tracing::{debug, info, warn}; + +use crate::update::host_sudo; + +const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh"); +const DOCTOR_SERVICE: &str = + include_str!("../../../image-recipe/configs/archipelago-doctor.service"); +const DOCTOR_TIMER: &str = + include_str!("../../../image-recipe/configs/archipelago-doctor.timer"); + +const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh"; +const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service"; +const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer"; + +/// Entry point called from main startup. Never returns an error to the caller — +/// failing to bootstrap the doctor must not prevent the backend from serving. +pub async fn ensure_doctor_installed() { + match run().await { + Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"), + Ok(_) => debug!("Doctor artifacts already in sync"), + Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e), + } +} + +async fn run() -> Result { + // Dev-box guard: on contributors' laptops `/home/archipelago/archy` is + // typically a symlink into the git checkout, and writing through it + // would clobber the working tree with whatever the binary happens to + // have been compiled from. Production ISO installs materialize a real + // directory. + let home_archy = Path::new("/home/archipelago/archy"); + if fs::symlink_metadata(home_archy) + .await + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + debug!("/home/archipelago/archy is a symlink — skipping doctor bootstrap (dev box)"); + return Ok(false); + } + + // Skip entirely on machines without the canonical scripts directory — + // writing orphan files there just causes confusion. + let scripts_dir = Path::new(DOCTOR_SH_PATH) + .parent() + .context("doctor script path has no parent")?; + if !scripts_dir.exists() { + debug!( + "Scripts dir {} missing — skipping doctor bootstrap", + scripts_dir.display() + ); + return Ok(false); + } + + let mut changed = false; + + // 1. Script — lives in archipelago's home dir, user-writable. + if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await { + fs::write(DOCTOR_SH_PATH, DOCTOR_SH) + .await + .with_context(|| format!("write {}", DOCTOR_SH_PATH))?; + let _ = tokio::process::Command::new("chmod") + .args(["+x", DOCTOR_SH_PATH]) + .status() + .await; + info!("Updated {}", DOCTOR_SH_PATH); + changed = true; + } + + // 2. Systemd unit files — /etc is restricted; route through host_sudo. + let service_changed = write_root_if_needed(DOCTOR_SERVICE_PATH, DOCTOR_SERVICE).await?; + let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?; + changed = changed || service_changed || timer_changed; + + // 3. Reload + enable. Only when we actually touched units, or when the + // timer isn't enabled yet (catches fresh upgrades of boxes that predate + // the doctor entirely). + let timer_enabled = is_timer_enabled().await; + if service_changed || timer_changed || !timer_enabled { + if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await { + warn!("daemon-reload failed: {:#}", e); + } + if let Err(e) = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]) + .await + { + warn!("enable archipelago-doctor.timer failed: {:#}", e); + } else if !timer_enabled { + info!("Enabled archipelago-doctor.timer"); + } + } + + Ok(changed) +} + +async fn needs_write(path: &str, expected: &str) -> bool { + match fs::read_to_string(path).await { + Ok(current) => current != expected, + Err(_) => true, + } +} + +/// Write content to a root-owned path via `sudo mv` of a user-owned tmp file. +/// Returns true if a write happened. +async fn write_root_if_needed(path: &str, content: &str) -> Result { + if !needs_write(path, content).await { + return Ok(false); + } + let tmp = format!( + "/tmp/archipelago-bootstrap-{}-{}.tmp", + std::process::id(), + Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unit") + ); + fs::write(&tmp, content) + .await + .with_context(|| format!("write tmp {}", tmp))?; + let status = host_sudo(&["mv", &tmp, path]) + .await + .with_context(|| format!("sudo mv {} -> {}", tmp, path))?; + if !status.success() { + let _ = fs::remove_file(&tmp).await; + anyhow::bail!("sudo mv to {} exited with {}", path, status); + } + info!("Updated {}", path); + Ok(true) +} + +async fn is_timer_enabled() -> bool { + tokio::process::Command::new("systemctl") + .args(["is-enabled", "--quiet", "archipelago-doctor.timer"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index c343d1da..7e7bed59 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -216,6 +216,80 @@ impl IdentityManager { Ok(record) } + /// Mirror an existing Ed25519 signing key as a manager-level identity. + /// + /// Used at boot to expose the node's own seed-derived key (the one that + /// backs `server_info.pubkey` and peer-to-peer connections) as an + /// entry in the Identities page, so all three surfaces — DID Status, + /// "Node" entry on Identities, and peer-connect DID — resolve to the + /// same DID. The id is deterministic (`node-`), so repeated + /// calls on the same key are idempotent: if the file already exists + /// we return the existing record untouched. + pub async fn create_from_signing_key( + &self, + name: String, + purpose: IdentityPurpose, + signing_key: SigningKey, + ) -> Result { + let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let did = did_key_from_pubkey_hex(&pubkey_hex)?; + let id = format!("node-{}", &pubkey_hex[..16]); + + // Idempotent: if we already mirrored this key, just return it. + let file_path = self.identities_dir.join(format!("{}.json", id)); + if file_path.exists() { + return self.get(&id).await; + } + + let created_at = chrono::Utc::now().to_rfc3339(); + // Mark as the node (master) identity so it gets the hex SVG. + let default_profile = IdentityProfile { + picture: Some(crate::avatar::default_picture(&pubkey_hex, true)), + ..Default::default() + }; + + let identity_file = IdentityFile { + id: id.clone(), + name: name.clone(), + purpose: purpose.clone(), + secret_key: signing_key.to_bytes().to_vec(), + pubkey_hex: pubkey_hex.clone(), + did: did.clone(), + created_at, + nostr_secret_hex: None, + nostr_pubkey_hex: None, + profile: Some(default_profile), + derivation_index: Some(0), + }; + + let json = serde_json::to_string_pretty(&identity_file) + .context("Failed to serialize identity")?; + fs::write(&file_path, json.as_bytes()) + .await + .context("Failed to write identity file")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600)) + .await + .context("Failed to set identity file permissions")?; + } + + // First identity becomes the default. + let (existing, _) = self.list().await?; + if existing.len() <= 1 { + self.set_default(&id).await?; + } + + tracing::info!( + "Mirrored node signing key as Node identity '{}' ({})", + name, + purpose + ); + + self.get(&id).await + } + /// Create a new identity with keys derived from a BIP-39 master seed. /// The derivation index is auto-incremented and persisted. pub async fn create_from_seed( diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 21f73be7..2c0a5546 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -28,6 +28,7 @@ mod avatar; mod backup; mod bitcoin_rpc; mod blobs; +mod bootstrap; mod config; mod constants; mod container; @@ -171,6 +172,11 @@ async fn main() -> Result<()> { update::run_update_scheduler(update_data_dir).await; }); + // Synchronize host-side doctor artifacts (script + systemd units) with + // what's embedded in this binary. Runs in the background so it never + // delays server readiness; best-effort, warnings only. + tokio::spawn(bootstrap::ensure_doctor_installed()); + // Spawn periodic container snapshot (for crash recovery) crash_recovery::spawn_snapshot_task(config.data_dir.clone()); diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index cebf2c75..f2bbd948 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -89,22 +89,32 @@ impl Server { // Load persisted messages (Archipelago channel) node_message::init(&config.data_dir).await; - // Auto-create default identity if none exist (fresh boot or factory reset) + // Auto-create the Node identity on fresh boot, mirroring the node's + // own signing key (seed-derived when onboarded, random otherwise). + // This keeps the DID shown on the Identities page, the DID Status + // card, and the DID used for peer-to-peer connects all aligned on + // one value — the seed-derived node DID. Idempotent: if the entry + // already exists from a prior boot, create_from_signing_key returns + // the existing record unchanged. { let im = crate::identity_manager::IdentityManager::new(&config.data_dir).await; if let Ok(mgr) = im { if let Ok((list, _)) = mgr.list().await { if list.is_empty() { + let signing_key = ed25519_dalek::SigningKey::from_bytes( + &identity.signing_key().to_bytes(), + ); match mgr - .create( - "Default".to_string(), + .create_from_signing_key( + "Node".to_string(), crate::identity_manager::IdentityPurpose::Personal, + signing_key, ) .await { Ok(record) => { let _ = mgr.create_nostr_key(&record.id).await; - tracing::info!(did = %record.did, "Auto-created default identity with Nostr key"); + tracing::info!(did = %record.did, "Auto-created Node identity mirroring node key"); } Err(e) => tracing::debug!("Auto-identity creation (non-fatal): {}", e), } diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 60b58ecf..979016c0 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -792,7 +792,7 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> { /// though sudo itself is root. `systemd-run --wait` spawns a transient /// service unit that inherits systemd's default protections (i.e. none /// of ours), escaping the namespace. -async fn host_sudo(args: &[&str]) -> Result { +pub(crate) async fn host_sudo(args: &[&str]) -> Result { let mut full: Vec<&str> = vec![ "systemd-run", "--wait", diff --git a/neode-ui/package.json b/neode-ui/package.json index 8939b508..40a3b1f3 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.6.0-alpha", + "version": "1.7.35-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index e12b932c..16d38780 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -19,6 +19,8 @@ "launch": "Launch", "starting": "Starting...", "stopping": "Stopping...", + "update": "Update", + "updating": "Updating...", "send": "Send", "sending": "Sending...", "back": "Back", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index c0b45b7d..6f169c1f 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -19,6 +19,8 @@ "launch": "Abrir", "starting": "Iniciando...", "stopping": "Deteniendo...", + "update": "Actualizar", + "updating": "Actualizando...", "send": "Enviar", "sending": "Enviando...", "back": "Volver", diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index d86652b2..f14c6fd9 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -117,6 +117,7 @@ @start="actions.startApp" @stop="actions.stopApp" @restart="actions.restartApp" + @update="updateApp" @show-uninstall="showUninstallModal" /> @@ -296,4 +297,12 @@ function goToApp(id: string) { function launchApp(id: string) { useAppLauncherStore().openSession(id) } + +async function updateApp(id: string) { + try { + await serverStore.updatePackage(id) + } catch (err) { + actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}` + } +} diff --git a/neode-ui/src/views/apps/AppCard.vue b/neode-ui/src/views/apps/AppCard.vue index 83a9a792..5a8abe5a 100644 --- a/neode-ui/src/views/apps/AppCard.vue +++ b/neode-ui/src/views/apps/AppCard.vue @@ -114,6 +114,29 @@
+ + + + + + + + + {{ t('common.updating') }} +