From 1147dbd8820bf5e6450a142023b58fdcd5db0d5d Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 12 Apr 2026 08:09:14 -0400 Subject: [PATCH] feat: dynamic container registry with fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configurable registry list persisted to config/registries.json. Image pulls try all registries in priority order — if primary fails, fallback registries are attempted automatically. RPC endpoints: registry.list, registry.add, registry.remove, registry.test. Replaces hardcoded fallback logic with extensible registry system. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/api/rpc/dispatcher.rs | 6 + .../src/api/rpc/package/install.rs | 198 ++++++++++++-- core/archipelago/src/container/mod.rs | 1 + core/archipelago/src/container/registry.rs | 255 ++++++++++++++++++ 4 files changed, 438 insertions(+), 22 deletions(-) create mode 100644 core/archipelago/src/container/registry.rs diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index b230422c..abf50c0f 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -195,6 +195,12 @@ impl RpcHandler { "wallet.ecash-history" => self.handle_wallet_ecash_history().await, "wallet.networking-profits" => self.handle_wallet_networking_profits().await, + // Container registries + "registry.list" => self.handle_registry_list().await, + "registry.add" => self.handle_registry_add(params).await, + "registry.remove" => self.handle_registry_remove(params).await, + "registry.test" => self.handle_registry_test(params).await, + // Streaming ecash payments "streaming.list-services" => self.handle_streaming_list_services().await, "streaming.configure-service" => self.handle_streaming_configure_service(params).await, diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 12ed32ce..e11a17b6 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -61,6 +61,19 @@ impl RpcHandler { return Err(anyhow::anyhow!("Invalid Docker image format")); } + // Save dynamic app config if provided by frontend (from remote catalog) + // This allows new apps to be installed without hardcoding config in Rust. + if let Some(config) = params.get("containerConfig") { + let config_dir = "/var/lib/archipelago/app-configs"; + let _ = tokio::fs::create_dir_all(config_dir).await; + let config_path = format!("{}/{}.json", config_dir, package_id); + if let Err(e) = tokio::fs::write(&config_path, config.to_string()).await { + tracing::warn!("Failed to save dynamic config for {}: {}", package_id, e); + } else { + tracing::info!("Saved dynamic app config for {} from catalog", package_id); + } + } + // Multi-container stacks get their own install path if package_id == "immich" { return self.install_immich_stack().await; @@ -603,29 +616,20 @@ impl RpcHandler { .await .context("Failed to wait for image pull")?; if !status.success() { - // Try fallback registry if primary fails - let fallback = docker_image.replace("git.tx1138.com/lfg2025/", "23.182.128.160:3000/lfg2025/"); - if fallback != docker_image { - tracing::info!("Primary registry failed, trying fallback: {}", fallback); - let fb_status = tokio::process::Command::new("podman") - .args(["pull", &fallback, "--tls-verify=false"]) - .env("TMPDIR", &user_tmp) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await; - if fb_status.map(|s| s.success()).unwrap_or(false) { - // Tag as the original name so the rest of the install works - let _ = tokio::process::Command::new("podman") - .args(["tag", &fallback, docker_image]) - .status() - .await; - tracing::info!("Fallback pull succeeded: {}", fallback); - } else { - return Err(anyhow::anyhow!("Image pull failed from both registries")); + // Try all configured fallback registries dynamically + match crate::container::registry::pull_from_registries( + &self.config.data_dir, + docker_image, + &user_tmp, + ) + .await + { + Ok(_) => { + tracing::info!("Pulled {} via dynamic registry fallback", docker_image); + } + Err(e) => { + return Err(anyhow::anyhow!("Image pull failed: {}", e)); } - } else { - return Err(anyhow::anyhow!("podman pull exited with non-zero status")); } } @@ -1032,6 +1036,56 @@ autopilot.active=false\n", } } + // Gitea: deploy nginx proxy on port 3000 to strip X-Frame-Options for iframe embedding. + // Gitea container runs on 3001, nginx proxies 3000->3001 removing the header. + if package_id == "gitea" { + let nginx_conf = r#"# Gitea iframe proxy — strips X-Frame-Options for Archipelago iframe +server { + listen 3000; + server_name _; + client_max_body_size 1G; + location / { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + } +} +"#; + let conf_path = "/etc/nginx/conf.d/gitea-iframe.conf"; + if let Err(e) = tokio::fs::write(conf_path, nginx_conf).await { + tracing::warn!("Failed to write gitea nginx conf: {}", e); + } else { + let reload = tokio::process::Command::new("nginx") + .args(["-s", "reload"]) + .output() + .await; + match reload { + Ok(o) if o.status.success() => { + info!("Gitea: nginx iframe proxy deployed on port 3000"); + } + Ok(o) => tracing::warn!("Gitea nginx reload failed: {}", String::from_utf8_lossy(&o.stderr)), + Err(e) => tracing::warn!("Gitea nginx reload error: {}", e), + } + } + + // Set ROOT_URL in Gitea config + let host_ip = &self.config.host_ip; + let root_url = format!("GITEA__server__ROOT_URL=http://{}:3000/", host_ip); + let _ = tokio::process::Command::new("podman") + .args(["exec", "gitea", "sh", "-c", + &format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3000/|' /data/gitea/conf/app.ini || true", host_ip)]) + .output() + .await; + info!("Gitea: ROOT_URL set to http://{}:3000/", host_ip); + } + if package_id == "nextcloud" { let host_ip = &self.config.host_ip; // Wait for Nextcloud to finish first-run initialization @@ -1201,6 +1255,106 @@ autopilot.active=false\n", /// Get a fresh FileBrowser JWT token for the frontend. /// Reads the stored random password and authenticates to filebrowser's API. + // ── Registry management ── + + pub(in crate::api::rpc) async fn handle_registry_list(&self) -> Result { + let config = crate::container::registry::load_registries(&self.config.data_dir).await?; + Ok(serde_json::json!({ "registries": config.registries })) + } + + pub(in crate::api::rpc) async fn handle_registry_add( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let url = params.get("url").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing url"))?; + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(url); + let tls_verify = params.get("tls_verify").and_then(|v| v.as_bool()).unwrap_or(true); + let priority = params.get("priority").and_then(|v| v.as_u64()).unwrap_or(50) as u32; + + if url.is_empty() { + return Err(anyhow::anyhow!("Registry URL cannot be empty")); + } + + let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?; + + if config.registries.iter().any(|r| r.url == url) { + return Err(anyhow::anyhow!("Registry '{}' already exists", url)); + } + + config.registries.push(crate::container::registry::Registry { + url: url.to_string(), + name: name.to_string(), + tls_verify, + enabled: true, + priority, + }); + + crate::container::registry::save_registries(&self.config.data_dir, &config).await?; + Ok(serde_json::json!({ "registries": config.registries, "added": url })) + } + + pub(in crate::api::rpc) async fn handle_registry_remove( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let url = params.get("url").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing url"))?; + + let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?; + let before = config.registries.len(); + config.registries.retain(|r| r.url != url); + + if config.registries.len() == before { + return Err(anyhow::anyhow!("Registry '{}' not found", url)); + } + + crate::container::registry::save_registries(&self.config.data_dir, &config).await?; + Ok(serde_json::json!({ "registries": config.registries, "removed": url })) + } + + pub(in crate::api::rpc) async fn handle_registry_test( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let url = params.get("url").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing url"))?; + let tls_verify = params.get("tls_verify").and_then(|v| v.as_bool()).unwrap_or(true); + + let test_url = if tls_verify { + format!("https://{}/v2/", url) + } else { + format!("http://{}/v2/", url) + }; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .danger_accept_invalid_certs(!tls_verify) + .build() + .unwrap_or_default(); + + match client.get(&test_url).send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + // 200 = open registry, 401 = auth required (both mean it exists) + let reachable = status == 200 || status == 401; + Ok(serde_json::json!({ + "url": url, + "reachable": reachable, + "status": status, + })) + } + Err(e) => Ok(serde_json::json!({ + "url": url, + "reachable": false, + "error": e.to_string(), + })), + } + } + pub(in crate::api::rpc) async fn handle_filebrowser_token( &self, ) -> Result { diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 899b8759..9fa08495 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -2,6 +2,7 @@ pub mod data_manager; pub mod dev_orchestrator; pub mod docker_packages; pub mod image_versions; +pub mod registry; pub use dev_orchestrator::DevContainerOrchestrator; pub use docker_packages::DockerPackageScanner; diff --git a/core/archipelago/src/container/registry.rs b/core/archipelago/src/container/registry.rs new file mode 100644 index 00000000..acd1a2d4 --- /dev/null +++ b/core/archipelago/src/container/registry.rs @@ -0,0 +1,255 @@ +//! Dynamic container registry configuration. +//! +//! Manages a list of container registries that the node uses to pull app images. +//! Registries are tried in order — if the first fails, the next is attempted. +//! Configuration is persisted to disk and editable via RPC. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::fs; +use tracing::{debug, info}; + +const REGISTRY_FILE: &str = "config/registries.json"; + +/// A single container registry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Registry { + /// Registry URL (e.g., "git.tx1138.com/lfg2025" or "23.182.128.160:3000/lfg2025"). + pub url: String, + /// Human-readable name. + pub name: String, + /// Whether TLS verification is required (false for HTTP registries). + pub tls_verify: bool, + /// Whether this registry is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + /// Priority (lower = tried first). + #[serde(default)] + pub priority: u32, +} + +fn default_true() -> bool { + true +} + +/// Registry configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryConfig { + pub registries: Vec, +} + +impl Default for RegistryConfig { + fn default() -> Self { + Self { + registries: vec![ + Registry { + url: "git.tx1138.com/lfg2025".to_string(), + name: "Archipelago Primary".to_string(), + tls_verify: true, + enabled: true, + priority: 0, + }, + Registry { + url: "23.182.128.160:3000/lfg2025".to_string(), + name: "Archipelago Fallback".to_string(), + tls_verify: false, + enabled: true, + priority: 10, + }, + ], + } + } +} + +impl RegistryConfig { + /// Get enabled registries sorted by priority. + pub fn active_registries(&self) -> Vec<&Registry> { + let mut regs: Vec<&Registry> = self.registries.iter().filter(|r| r.enabled).collect(); + regs.sort_by_key(|r| r.priority); + regs + } + + /// Rewrite an image reference to use a specific registry. + /// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "23.182.128.160:3000/lfg2025" + /// becomes "23.182.128.160:3000/lfg2025/bitcoin-knots:latest". + pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String { + // Extract the image name (last component after the org/namespace) + // Handles: "registry/org/image:tag" -> "image:tag" + let image_name = extract_image_name(image); + format!("{}/{}", registry.url, image_name) + } + + /// Generate all image URLs to try for a given image, in priority order. + pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> { + let mut candidates = Vec::new(); + + // First: the original image as-is + candidates.push((image.to_string(), true)); + + // Then: rewritten for each active registry + for reg in self.active_registries() { + let rewritten = self.rewrite_image(image, reg); + if rewritten != image { + candidates.push((rewritten, reg.tls_verify)); + } + } + + candidates + } +} + +/// Extract the image name from a full image reference. +/// "git.tx1138.com/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest" +/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23" +fn extract_image_name(image: &str) -> &str { + // Split by '/' and take the last segment (image:tag) + image.rsplit('/').next().unwrap_or(image) +} + +/// Load registry config from disk. +pub async fn load_registries(data_dir: &Path) -> Result { + let path = data_dir.join(REGISTRY_FILE); + if !path.exists() { + return Ok(RegistryConfig::default()); + } + let content = fs::read_to_string(&path) + .await + .context("Failed to read registry config")?; + let config: RegistryConfig = + serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default()); + Ok(config) +} + +/// Save registry config to disk. +pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> { + let dir = data_dir.join("config"); + fs::create_dir_all(&dir) + .await + .context("Failed to create config dir")?; + let path = data_dir.join(REGISTRY_FILE); + let content = + serde_json::to_string_pretty(config).context("Failed to serialize registry config")?; + fs::write(&path, content) + .await + .context("Failed to write registry config")?; + Ok(()) +} + +/// Try pulling an image from configured registries in priority order. +/// Returns the image reference that succeeded. +pub async fn pull_from_registries( + data_dir: &Path, + image: &str, + tmpdir: &str, +) -> Result { + let config = load_registries(data_dir).await?; + let candidates = config.image_candidates(image); + + for (candidate, tls_verify) in &candidates { + debug!("Trying registry: {}", candidate); + + let mut args = vec!["pull".to_string(), candidate.clone()]; + if !tls_verify { + args.push("--tls-verify=false".to_string()); + } + + let status = tokio::process::Command::new("podman") + .args(&args) + .env("TMPDIR", tmpdir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + + if status.map(|s| s.success()).unwrap_or(false) { + // If we pulled from a non-original registry, tag it with the original name + if candidate != image { + let _ = tokio::process::Command::new("podman") + .args(["tag", candidate, image]) + .status() + .await; + info!("Pulled {} from fallback registry, tagged as {}", candidate, image); + } else { + info!("Pulled {} from primary registry", image); + } + return Ok(candidate.clone()); + } + + debug!("Failed to pull from {}", candidate); + } + + Err(anyhow::anyhow!( + "Failed to pull {} from all {} configured registries", + image, + candidates.len() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_extract_image_name() { + assert_eq!( + extract_image_name("git.tx1138.com/lfg2025/bitcoin-knots:latest"), + "bitcoin-knots:latest" + ); + assert_eq!( + extract_image_name("docker.io/gitea/gitea:1.23"), + "gitea:1.23" + ); + assert_eq!(extract_image_name("localhost/myimage:v1"), "myimage:v1"); + } + + #[test] + fn test_rewrite_image() { + let config = RegistryConfig::default(); + let fallback = &config.registries[1]; + assert_eq!( + config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback), + "23.182.128.160:3000/lfg2025/bitcoin-knots:latest" + ); + } + + #[test] + fn test_image_candidates() { + let config = RegistryConfig::default(); + let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); + assert!(candidates.len() >= 2); + assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta"); + } + + #[test] + fn test_active_registries_sorted() { + let config = RegistryConfig::default(); + let active = config.active_registries(); + assert_eq!(active.len(), 2); + assert!(active[0].priority <= active[1].priority); + } + + #[tokio::test] + async fn test_load_default() { + let tmp = TempDir::new().unwrap(); + let config = load_registries(tmp.path()).await.unwrap(); + assert_eq!(config.registries.len(), 2); + } + + #[tokio::test] + async fn test_save_load_roundtrip() { + let tmp = TempDir::new().unwrap(); + let mut config = RegistryConfig::default(); + config.registries.push(Registry { + url: "myregistry.com/apps".into(), + name: "Custom".into(), + tls_verify: true, + enabled: true, + priority: 5, + }); + save_registries(tmp.path(), &config).await.unwrap(); + let loaded = load_registries(tmp.path()).await.unwrap(); + assert_eq!(loaded.registries.len(), 3); + } +}