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') }} +