Merge fix/btcpay-lnd-plugins-doctor: btcpay plugins/LND wiring, doctor netns rebuild+latch, tx1138 purge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-10 20:12:21 +01:00
commit f318009308
35 changed files with 2766 additions and 2472 deletions

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "indeedhub"] [submodule "indeedhub"]
path = indeedhub path = indeedhub
url = https://git.tx1138.com/lfg2025/indeehub.git url = http://146.59.87.168:3000/lfg2025/indeehub.git

View File

@ -21,7 +21,7 @@ Add an entry to `catalog.json`:
"icon": "/assets/img/app-icons/my-app.svg", "icon": "/assets/img/app-icons/my-app.svg",
"author": "Author", "author": "Author",
"category": "data", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/my-app:1.0.0", "dockerImage": "146.59.87.168:3000/lfg2025/my-app:1.0.0",
"repoUrl": "https://github.com/...", "repoUrl": "https://github.com/...",
"containerConfig": { "containerConfig": {
"ports": ["8080:8080"], "ports": ["8080:8080"],

View File

@ -172,7 +172,7 @@
"author": "File Browser", "author": "File Browser",
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0", "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser", "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png", "icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint", "author": "Fedimint",
"category": "money", "category": "money",
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0", "dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint", "repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [

View File

@ -13,6 +13,12 @@ app:
secret_file: bitcoin-rpc-password secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS - key: BTCPAY_DB_PASS
secret_file: btcpay-db-password secret_file: btcpay-db-password
# Internal LND node. Generated by the daemon (lnd macaroon as hex +
# tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret.
# Optional: nodes without LND run btcpay without an internal node.
- key: BTCPAY_BTCLIGHTNING
secret_file: btcpay-lnd-connection
optional: true
derived_env: derived_env:
- key: BTCPAY_HOST - key: BTCPAY_HOST
template: "{{HOST_IP}}:23000" template: "{{HOST_IP}}:23000"
@ -50,6 +56,10 @@ app:
- ASPNETCORE_URLS=http://0.0.0.0:49392 - ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http - BTCPAY_PROTOCOL=http
- BTCPAY_CHAINS=btc - BTCPAY_CHAINS=btc
# Plugins must live on the persistent volume: the image default
# (/root/.btcpayserver/Plugins) is container-local, so every recreate
# silently wiped installed plugins.
- BTCPAY_PLUGINDIR=/datadir/Plugins
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 - BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 - BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago - BTCPAY_BTCRPCUSER=archipelago

View File

@ -12,7 +12,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}" FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
VERSION="${1:-latest}" VERSION="${1:-latest}"
REGISTRY="${REGISTRY:-git.tx1138.com}" REGISTRY="${REGISTRY:-146.59.87.168:3000}"
NAMESPACE="${NAMESPACE:-lfg2025}" NAMESPACE="${NAMESPACE:-lfg2025}"
IMAGE_NAME="indeedhub" IMAGE_NAME="indeedhub"
RUNTIME="${RUNTIME:-podman}" RUNTIME="${RUNTIME:-podman}"

View File

@ -126,15 +126,15 @@ impl ApiHandler {
} }
/// Server-side fetch of the upstream app catalog so the browser can /// Server-side fetch of the upstream app catalog so the browser can
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or /// load it without fighting CORS (upstream Gitea emits no ACAO) or
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream /// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
/// list is derived from the operator's configured container registries /// list is derived from the operator's configured container registries
/// so switching mirrors in Settings changes the App Store source too — /// so switching mirrors in Settings changes the App Store source too —
/// each active registry contributes one Gitea `raw/branch/main/catalog.json` /// each active registry contributes one Gitea `raw/branch/main/catalog.json`
/// URL (http or https per `tls_verify`), tried in priority order. /// URL (http or https per `tls_verify`), tried in priority order.
/// If registry config can't be loaded, falls back to the legacy /// If registry config can't be loaded, falls back to the hardcoded OVH
/// hardcoded pair so the App Store still renders on nodes that haven't /// URL so the App Store still renders on nodes that haven't persisted
/// persisted a registry config yet. 15s total timeout. /// a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> { async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
let mut upstreams: Vec<String> = Vec::new(); let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
@ -155,10 +155,6 @@ impl ApiHandler {
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json" "http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(), .to_string(),
); );
upstreams.push(
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
} }
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
@ -527,7 +523,7 @@ impl ApiHandler {
// App-catalog proxy — fetches catalog.json from the configured // App-catalog proxy — fetches catalog.json from the configured
// upstream URLs server-side so the browser doesn't hit CORS // upstream URLs server-side so the browser doesn't hit CORS
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream // (upstream Gitea has no ACAO header) or CSP (IP-port upstream
// falls outside `connect-src`). Session-authenticated so only // falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches. // the logged-in node owner can spin up fetches.
(Method::GET, "/api/app-catalog") => { (Method::GET, "/api/app-catalog") => {

View File

@ -5,11 +5,12 @@
//! 1.8.0 hardening plan). //! 1.8.0 hardening plan).
/// Registries images may be pulled from with an explicit host part. /// Registries images may be pulled from with an explicit host part.
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
/// never be pulled through again.)
pub const TRUSTED_REGISTRIES: &[&str] = &[ pub const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io", "docker.io",
"ghcr.io", "ghcr.io",
"localhost", "localhost",
"git.tx1138.com",
"146.59.87.168:3000", "146.59.87.168:3000",
]; ];
@ -58,13 +59,19 @@ mod tests {
"docker.io/library/nginx:1.25", "docker.io/library/nginx:1.25",
"ghcr.io/owner/app:latest", "ghcr.io/owner/app:latest",
"localhost/archy-dev:1", "localhost/archy-dev:1",
"git.tx1138.com/lfg2025/x:2",
"146.59.87.168:3000/archy/bitcoin-knots:28.1", "146.59.87.168:3000/archy/bitcoin-knots:28.1",
] { ] {
assert!(is_valid_docker_image(img), "{img} should be accepted"); assert!(is_valid_docker_image(img), "{img} should be accepted");
} }
} }
#[test]
fn rejects_retired_tx1138_registry() {
// Retired 2026-07-10 — refs through the dead host must be refused
// at the pull site, not time out against it.
assert!(!is_valid_docker_image("git.tx1138.com/lfg2025/x:2"));
}
#[test] #[test]
fn accepts_docker_hub_shorthand() { fn accepts_docker_hub_shorthand() {
for img in ["nginx", "grafana/grafana:11.2.0", "lightninglabs/lnd:v0.18"] { for img in ["nginx", "grafana/grafana:11.2.0", "lightninglabs/lnd:v0.18"] {

View File

@ -234,7 +234,7 @@ pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<
} }
/// Extract version tag from a full image reference. /// Extract version tag from a full image reference.
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta" /// e.g. "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty. /// Returns "latest" if no tag or tag is empty.
pub fn extract_version_from_image(image: &str) -> String { pub fn extract_version_from_image(image: &str) -> String {
// Split off the tag after the last colon, but only if it comes after the last slash // Split off the tag after the last colon, but only if it comes after the last slash
@ -328,11 +328,11 @@ mod tests {
#[test] #[test]
fn test_extract_version() { fn test_extract_version() {
assert_eq!( assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"), extract_version_from_image("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
"v0.18.4-beta" "v0.18.4-beta"
); );
assert_eq!( assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/grafana:10.2.0"), extract_version_from_image("146.59.87.168:3000/lfg2025/grafana:10.2.0"),
"10.2.0" "10.2.0"
); );
assert_eq!( assert_eq!(
@ -340,7 +340,7 @@ mod tests {
"latest" "latest"
); );
assert_eq!( assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/bitcoin-knots:latest"), extract_version_from_image("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"),
"latest" "latest"
); );
} }
@ -352,7 +352,7 @@ mod tests {
"lfg2025/lnd" "lfg2025/lnd"
); );
assert_eq!( assert_eq!(
image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"), image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
"lfg2025/lnd" "lfg2025/lnd"
); );
} }
@ -369,7 +369,7 @@ mod tests {
assert_eq!( assert_eq!(
available_update_for_images( available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29", "146.59.87.168:3000/lfg2025/nextcloud:29",
"git.tx1138.com/lfg2025/nextcloud:29", "146.59.87.168:3000/lfg2025/nextcloud:29",
), ),
None None
); );
@ -389,7 +389,7 @@ mod tests {
#[test] #[test]
fn test_parse_image_versions() { fn test_parse_image_versions() {
let content = r#" let content = r#"
ARCHY_REGISTRY="git.tx1138.com/lfg2025" ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta" LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0" GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
# comment # comment
@ -398,11 +398,11 @@ NOT_AN_IMAGE="something"
let parsed = parse_image_versions(content); let parsed = parse_image_versions(content);
assert_eq!( assert_eq!(
parsed.get("LND_IMAGE"), parsed.get("LND_IMAGE"),
Some(&"git.tx1138.com/lfg2025/lnd:v0.18.4-beta".to_string()) Some(&"146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta".to_string())
); );
assert_eq!( assert_eq!(
parsed.get("GRAFANA_IMAGE"), parsed.get("GRAFANA_IMAGE"),
Some(&"git.tx1138.com/lfg2025/grafana:10.2.0".to_string()) Some(&"146.59.87.168:3000/lfg2025/grafana:10.2.0".to_string())
); );
assert!(!parsed.contains_key("NOT_AN_IMAGE")); assert!(!parsed.contains_key("NOT_AN_IMAGE"));
assert!(!parsed.contains_key("ARCHY_REGISTRY")); assert!(!parsed.contains_key("ARCHY_REGISTRY"));

View File

@ -670,6 +670,73 @@ fn has_required_lnd_flags(conf: &str, rpc_pass: &str, bitcoin_host: &str) -> boo
.all(|needle| conf.lines().any(|line| line.trim() == *needle)) .all(|needle| conf.lines().any(|line| line.trim() == *needle))
} }
/// Secret file consumed by btcpay-server's optional `BTCPAY_BTCLIGHTNING`
/// secret_env (see apps/btcpay-server/manifest.yml).
const BTCPAY_LND_CONNECTION_SECRET: &str = "btcpay-lnd-connection";
/// Materialise the BTCPay→internal-LND connection-string secret.
///
/// LND's datadir is owned by its container subuid (100999 on a stock node),
/// so btcpay cannot bind-mount the macaroon — EACCES across the userns
/// boundary. The connection string therefore carries the macaroon inline as
/// hex, delivered by reference through the podman secret store.
///
/// No-op when LND isn't provisioned yet (missing tls.cert or macaroon) —
/// btcpay's secret_env entry is `optional`, so it simply starts without an
/// internal Lightning node and picks it up on a later reconcile tick.
/// Rewrites when the pinned cert thumbprint no longer matches (LND TLS cert
/// rotation). Macaroon rotation without cert rotation is not auto-detected
/// (reading the macaroon needs sudo; probing it every tick is not worth the
/// churn) — delete the secret file once to force regeneration.
pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path) -> Result<()> {
let cert_path = format!("{DEFAULT_DATA_DIR}/tls.cert");
let pem = match fs::read_to_string(&cert_path).await {
Ok(s) => s,
Err(_) => return Ok(()), // LND not installed/provisioned yet
};
let thumbprint =
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
// Fast path (no sudo): existing secret already pins the current cert.
if let Ok(existing) = fs::read_to_string(&target).await {
if !existing.trim().is_empty()
&& existing.contains(&format!("certthumbprint={thumbprint}"))
{
return Ok(());
}
}
let macaroon_path = format!("{DEFAULT_DATA_DIR}/data/chain/bitcoin/mainnet/admin.macaroon");
if !file_exists_as_root(&macaroon_path).await {
return Ok(()); // wallet not created yet; next tick retries
}
let macaroon = read_file_as_root(&macaroon_path).await?;
let value = format!(
"type=lnd-rest;server=https://lnd:8080/;macaroon={};certthumbprint={}",
hex::encode(macaroon),
thumbprint
);
crate::container::secrets::write_secret_file(&target, &value)
.context("writing btcpay-lnd-connection secret")
}
/// SHA256 over the DER certificate body (matches
/// `openssl x509 -fingerprint -sha256` without colons) — the format BTCPay's
/// `certthumbprint=` connection-string parameter expects.
fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
use sha2::{Digest, Sha256};
let b64: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
let der = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.context("decoding tls.cert PEM body")?;
Ok(hex::encode_upper(Sha256::digest(&der)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -2251,7 +2251,7 @@ impl ProdContainerOrchestrator {
// Reconcile recreates route through here too: an unreachable // Reconcile recreates route through here too: an unreachable
// registry must not brick an app whose exact image:tag is // registry must not brick an app whose exact image:tag is
// already in local storage (boot reconcile of archy-btcpay-db // already in local storage (boot reconcile of archy-btcpay-db
// /archy-nbxplorer with git.tx1138.com dead, 2026-07-10). // /archy-nbxplorer with the (since-retired) upstream registry dead, 2026-07-10).
// Same exists-first semantics as // Same exists-first semantics as
// ensure_resolved_source_available and the quadlets' // ensure_resolved_source_available and the quadlets'
// Pull=never. // Pull=never.
@ -3162,6 +3162,17 @@ impl ProdContainerOrchestrator {
.await .await
.context("ensuring bitcoin tx-relay credentials")?; .context("ensuring bitcoin tx-relay credentials")?;
} }
if app_id == "btcpay-server" {
// Derived secret (LND macaroon + cert thumbprint), consumed by an
// `optional` secret_env — btcpay must still start when LND is
// absent or the derivation fails, so log-and-continue.
if let Err(e) =
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir)
.await
{
tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node");
}
}
// Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now // Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now
// declared as `generated_secrets` in their manifests and materialised // declared as `generated_secrets` in their manifests and materialised
// generically in `resolve_dynamic_env` — no per-app code here. // generically in `resolve_dynamic_env` — no per-app code here.
@ -4837,7 +4848,7 @@ app:
name: File Browser name: File Browser
version: 1.0.0 version: 1.0.0
container: container:
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0 image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0
custom_args: custom_args:
- --config - --config
- /data/.filebrowser.json - /data/.filebrowser.json
@ -4861,7 +4872,7 @@ app:
name: LND name: LND
version: 1.0.0 version: 1.0.0
container: container:
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
secret_env: secret_env:
- key: BITCOIND_RPCPASS - key: BITCOIND_RPCPASS
secret_file: bitcoin-rpc-password secret_file: bitcoin-rpc-password
@ -4948,7 +4959,7 @@ app:
async fn install_fresh_skips_pull_when_image_local() { async fn install_fresh_skips_pull_when_image_local() {
// An unreachable registry must not brick a reconcile recreate whose // An unreachable registry must not brick a reconcile recreate whose
// exact image:tag is already in local storage (archy-btcpay-db / // exact image:tag is already in local storage (archy-btcpay-db /
// archy-nbxplorer boot reconcile with git.tx1138.com dead, // archy-nbxplorer boot reconcile with the (since-retired) upstream registry dead,
// 2026-07-10). // 2026-07-10).
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
rt.mark_image_present("docker.io/bitcoin/knots:28"); rt.mark_image_present("docker.io/bitcoin/knots:28");

View File

@ -11,12 +11,17 @@ use tokio::fs;
const REGISTRY_FILE: &str = "config/registries.json"; const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025"; const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025"; /// Retired registry host (release server retired 2026-06-13; the registry
/// frontend was fully dead by 2026-07-10 — 500 on every /v2 manifest read).
/// Never a default, never force-enabled; stripped from saved configs on
/// load. The literal exists ONLY so the strip can match — nothing may pull
/// through this host.
const RETIRED_TX1138_HOST: &str = "git.tx1138.com";
/// A single container registry. /// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registry { pub struct Registry {
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "146.59.87.168:3000/lfg2025"). /// Registry URL (e.g., "146.59.87.168:3000/lfg2025").
pub url: String, pub url: String,
/// Human-readable name. /// Human-readable name.
pub name: String, pub name: String,
@ -43,22 +48,13 @@ pub struct RegistryConfig {
impl Default for RegistryConfig { impl Default for RegistryConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
registries: vec![ registries: vec![Registry {
Registry {
url: OVH_REGISTRY_URL.to_string(), url: OVH_REGISTRY_URL.to_string(),
name: "Server 1 (OVH)".to_string(), name: "Server 1 (OVH)".to_string(),
tls_verify: false, tls_verify: false,
enabled: true, enabled: true,
priority: 0, priority: 0,
}, }],
Registry {
url: TX1138_REGISTRY_URL.to_string(),
name: "Server 2 (tx1138)".to_string(),
tls_verify: true,
enabled: true,
priority: 10,
},
],
} }
} }
} }
@ -72,7 +68,7 @@ impl RegistryConfig {
} }
/// Rewrite an image reference to use a specific registry. /// Rewrite an image reference to use a specific registry.
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025" /// E.g., "docker.io/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025"
/// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest". /// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest".
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String { pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
// Extract the image name (last component after the org/namespace) // Extract the image name (last component after the org/namespace)
@ -83,7 +79,7 @@ impl RegistryConfig {
} }
/// Extract the image name from a full image reference. /// Extract the image name from a full image reference.
/// "git.tx1138.com/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest" /// "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23" /// "docker.io/gitea/gitea:1.23" -> "gitea:1.23"
fn extract_image_name(image: &str) -> &str { fn extract_image_name(image: &str) -> &str {
// Split by '/' and take the last segment (image:tag) // Split by '/' and take the last segment (image:tag)
@ -118,6 +114,12 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
config config
.registries .registries
.retain(|r| !r.url.contains("23.182.128.160")); .retain(|r| !r.url.contains("23.182.128.160"));
// Same treatment for the retired tx1138 registry (was Server 2 in older
// defaults): strip it on load so nothing ever pulls through the dead
// host again.
config
.registries
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
let mut changed = config.registries.len() != before; let mut changed = config.registries.len() != before;
// Migrate: any default registry URL that isn't already in the // Migrate: any default registry URL that isn't already in the
@ -178,12 +180,6 @@ fn force_ovh_registry_primary(config: &mut RegistryConfig) {
registry.enabled = true; registry.enabled = true;
registry.priority = 0; registry.priority = 0;
} }
TX1138_REGISTRY_URL => {
registry.name = "Server 2 (tx1138)".to_string();
registry.tls_verify = true;
registry.enabled = true;
registry.priority = 10;
}
_ => { _ => {
if registry.priority <= 10 { if registry.priority <= 10 {
registry.priority = registry.priority.saturating_add(20); registry.priority = registry.priority.saturating_add(20);
@ -216,7 +212,7 @@ mod tests {
#[test] #[test]
fn test_extract_image_name() { fn test_extract_image_name() {
assert_eq!( assert_eq!(
extract_image_name("git.tx1138.com/lfg2025/bitcoin-knots:latest"), extract_image_name("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"),
"bitcoin-knots:latest" "bitcoin-knots:latest"
); );
assert_eq!( assert_eq!(
@ -229,11 +225,11 @@ mod tests {
#[test] #[test]
fn test_rewrite_image() { fn test_rewrite_image() {
let config = RegistryConfig::default(); let config = RegistryConfig::default();
// Default primary is now the OVH VPS (index 0). A tx1138-hardcoded // An image hardcoded to some other registry rewrites to OVH when
// image rewrites to OVH when asked for the primary mirror. // asked for the primary mirror.
let primary = &config.registries[0]; let primary = &config.registries[0];
assert_eq!( assert_eq!(
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary), config.rewrite_image("docker.io/lfg2025/bitcoin-knots:latest", primary),
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest" "146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
); );
} }
@ -242,15 +238,51 @@ mod tests {
fn test_active_registries_sorted() { fn test_active_registries_sorted() {
let config = RegistryConfig::default(); let config = RegistryConfig::default();
let active = config.active_registries(); let active = config.active_registries();
assert_eq!(active.len(), 2); assert_eq!(active.len(), 1);
assert!(active[0].priority <= active[1].priority); assert_eq!(active[0].url, OVH_REGISTRY_URL);
} }
#[tokio::test] #[tokio::test]
async fn test_load_default() { async fn test_load_default() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let config = load_registries(tmp.path()).await.unwrap(); let config = load_registries(tmp.path()).await.unwrap();
assert_eq!(config.registries.len(), 2); assert_eq!(config.registries.len(), 1);
}
#[tokio::test]
async fn test_load_strips_retired_tx1138_registry() {
// Nodes provisioned before the retirement have the tx1138 registry
// baked into their saved config (was Server 2). It must be stripped
// on load and never re-added by the defaults merge.
let tmp = TempDir::new().unwrap();
let config = RegistryConfig {
registries: vec![
Registry {
url: format!("{RETIRED_TX1138_HOST}/lfg2025"),
name: "Server 2 (tx1138)".into(),
tls_verify: true,
enabled: true,
priority: 10,
},
Registry {
url: OVH_REGISTRY_URL.into(),
name: "Server 1 (OVH)".into(),
tls_verify: false,
enabled: true,
priority: 0,
},
],
};
save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap();
assert!(
!loaded
.registries
.iter()
.any(|r| r.url.contains(RETIRED_TX1138_HOST)),
"retired tx1138 registry must be stripped on load; got {:?}",
loaded.registries
);
} }
#[tokio::test] #[tokio::test]
@ -266,6 +298,6 @@ mod tests {
}); });
save_registries(tmp.path(), &config).await.unwrap(); save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap(); let loaded = load_registries(tmp.path()).await.unwrap();
assert_eq!(loaded.registries.len(), 3); assert_eq!(loaded.registries.len(), 2);
} }
} }

View File

@ -102,6 +102,17 @@ fn random_base64(bytes: usize) -> String {
base64::engine::general_purpose::STANDARD.encode(buf) base64::engine::general_purpose::STANDARD.encode(buf)
} }
/// Write an externally computed secret value (0600, atomic). For derived
/// secrets that aren't random generators — e.g. the btcpay internal-LND
/// connection string assembled in `container::lnd`.
pub(crate) fn write_secret_file(path: &Path, value: &str) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.with_context(|| format!("creating secrets dir {}", dir.display()))?;
}
write_secret(path, value)
}
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename /// Atomically write a `0600` secret: a temp file in the same dir (so the rename
/// is atomic), fsynced, then renamed over the target. /// is atomic), fsynced, then renamed over the target.
fn write_secret(path: &Path, value: &str) -> Result<()> { fn write_secret(path: &Path, value: &str) -> Result<()> {

View File

@ -2132,9 +2132,9 @@ mod tests {
fn test_manifest_origin_parses_https() { fn test_manifest_origin_parses_https() {
assert_eq!( assert_eq!(
manifest_origin( manifest_origin(
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json" "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
), ),
Some("https://git.tx1138.com".to_string()) Some("https://releases.example.com".to_string())
); );
} }
@ -2151,7 +2151,7 @@ mod tests {
#[test] #[test]
fn test_manifest_origin_rejects_garbage() { fn test_manifest_origin_rejects_garbage() {
assert_eq!(manifest_origin("not a url"), None); assert_eq!(manifest_origin("not a url"), None);
assert_eq!(manifest_origin("ftp://git.tx1138.com/x"), None); assert_eq!(manifest_origin("ftp://releases.example.com/x"), None);
} }
#[test] #[test]
@ -2165,7 +2165,7 @@ mod tests {
name: "archipelago".into(), name: "archipelago".into(),
current_version: "1.7.25-alpha".into(), current_version: "1.7.25-alpha".into(),
new_version: "1.7.26-alpha".into(), new_version: "1.7.26-alpha".into(),
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(), download_url: "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
sha256: "x".into(), sha256: "x".into(),
size_bytes: 1, size_bytes: 1,
blake3: None, blake3: None,
@ -2174,7 +2174,7 @@ mod tests {
name: "frontend".into(), name: "frontend".into(),
current_version: "1.7.25-alpha".into(), current_version: "1.7.25-alpha".into(),
new_version: "1.7.26-alpha".into(), new_version: "1.7.26-alpha".into(),
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(), download_url: "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
sha256: "y".into(), sha256: "y".into(),
size_bytes: 2, size_bytes: 2,
blake3: None, blake3: None,
@ -2218,6 +2218,8 @@ mod tests {
label: "Server 1 (OVH)".to_string(), label: "Server 1 (OVH)".to_string(),
}, },
UpdateMirror { UpdateMirror {
// Deliberately the retired host: this fixture exists to prove
// load_mirrors strips it.
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json" url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(), .to_string(),
label: "Server 2 (tx1138)".to_string(), label: "Server 2 (tx1138)".to_string(),
@ -2406,7 +2408,7 @@ mod tests {
rollback_available: false, rollback_available: false,
schedule: UpdateSchedule::Manual, schedule: UpdateSchedule::Manual,
manifest_mirror: Some( manifest_mirror: Some(
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json" "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(), .to_string(),
), ),
manifest_signed: false, manifest_signed: false,

View File

@ -279,6 +279,12 @@ pub struct DerivedEnv {
pub struct SecretEnv { pub struct SecretEnv {
pub key: String, pub key: String,
pub secret_file: String, pub secret_file: String,
/// When true, a missing/unreadable/empty secret skips this entry instead
/// of failing the whole resolution. For integrations that exist on some
/// nodes only (btcpay's internal-LND connection string: nodes without
/// LND must still run btcpay, just without the internal node).
#[serde(default)]
pub optional: bool,
} }
/// A fully resolved secret env entry, produced at apply time. `value` lives /// A fully resolved secret env entry, produced at apply time. `value` lives
@ -1353,10 +1359,18 @@ impl ContainerConfig {
) -> Result<Vec<(String, String)>, ManifestError> { ) -> Result<Vec<(String, String)>, ManifestError> {
let mut out = Vec::with_capacity(self.secret_env.len()); let mut out = Vec::with_capacity(self.secret_env.len());
for e in &self.secret_env { for e in &self.secret_env {
let v = provider.read(&e.secret_file)?; let v = match provider.read(&e.secret_file) {
Ok(v) => v,
Err(_) if e.optional => continue,
Err(err) => return Err(err),
};
// An empty secret produces e.g. `-rpcpassword=` and crashes // An empty secret produces e.g. `-rpcpassword=` and crashes
// the container on auth before logs are useful. Fail loud. // the container on auth before logs are useful. Fail loud —
// unless the entry is optional, where empty means "not set".
if v.trim().is_empty() { if v.trim().is_empty() {
if e.optional {
continue;
}
return Err(ManifestError::Invalid(format!( return Err(ManifestError::Invalid(format!(
"secret_env {} resolved to empty value (file: {})", "secret_env {} resolved to empty value (file: {})",
e.key, e.secret_file e.key, e.secret_file
@ -2034,10 +2048,12 @@ app:
SecretEnv { SecretEnv {
key: "FM_BITCOIND_PASSWORD".to_string(), key: "FM_BITCOIND_PASSWORD".to_string(),
secret_file: "bitcoin-rpc-password".to_string(), secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
}, },
SecretEnv { SecretEnv {
key: "FM_GATEWAY_PASSWORD".to_string(), key: "FM_GATEWAY_PASSWORD".to_string(),
secret_file: "fedimint-gateway-password".to_string(), secret_file: "fedimint-gateway-password".to_string(),
optional: false,
}, },
], ],
generated_secrets: vec![], generated_secrets: vec![],
@ -2079,6 +2095,7 @@ app:
secret_env: vec![SecretEnv { secret_env: vec![SecretEnv {
key: "BITCOIN_RPC_PASS".to_string(), key: "BITCOIN_RPC_PASS".to_string(),
secret_file: "bitcoin-rpc-password".to_string(), secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
}], }],
generated_secrets: vec![], generated_secrets: vec![],
generated_certs: vec![], generated_certs: vec![],
@ -2099,6 +2116,51 @@ app:
} }
} }
#[test]
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
network_aliases: vec![],
custom_args: vec![],
entrypoint: None,
derived_env: vec![],
secret_env: vec![
SecretEnv {
key: "REQUIRED".to_string(),
secret_file: "present".to_string(),
optional: false,
},
SecretEnv {
key: "OPT_MISSING".to_string(),
secret_file: "does-not-exist".to_string(),
optional: true,
},
SecretEnv {
key: "OPT_EMPTY".to_string(),
secret_file: "empty".to_string(),
optional: true,
},
],
generated_secrets: vec![],
generated_certs: vec![],
data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
};
let p = MapSecretsProvider {
data: HashMap::from([
("present".to_string(), "value".to_string()),
("empty".to_string(), " \n".to_string()),
]),
};
let out = c.resolve_secret_env(&p).unwrap();
assert_eq!(out, vec!["REQUIRED=value".to_string()]);
}
#[test] #[test]
fn unsafe_manifest_values_are_rejected() { fn unsafe_manifest_values_are_rejected() {
let cases = [ let cases = [

View File

@ -885,8 +885,9 @@ mod tests {
assert!(!image_uses_insecure_registry( assert!(!image_uses_insecure_registry(
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0" "23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
)); ));
// HTTPS registries never match the insecure list.
assert!(!image_uses_insecure_registry( assert!(!image_uses_insecure_registry(
"git.tx1138.com/lfg2025/bitcoin-knots:latest" "ghcr.io/lfg2025/bitcoin-knots:latest"
)); ));
assert!(!image_uses_insecure_registry( assert!(!image_uses_insecure_registry(
"docker.io/library/nginx:latest" "docker.io/library/nginx:latest"

View File

@ -304,7 +304,7 @@ media (latest artifact only one minor behind).
- [ ] 🟠 **Sign + checksum the ISO.** Pipeline ends at `xorriso` with no `SHA256SUMS`, no - [ ] 🟠 **Sign + checksum the ISO.** Pipeline ends at `xorriso` with no `SHA256SUMS`, no
GPG/minisign, no Secure Boot (`BOOTX64.EFI` is unsigned though `grub-efi-amd64-signed` is GPG/minisign, no Secure Boot (`BOOTX64.EFI` is unsigned though `grub-efi-amd64-signed` is
installed). Emit + sign checksums; wire signed Secure Boot. installed). Emit + sign checksums; wire signed Secure Boot.
- [ ] 🟠 **Registries over HTTPS in the image too**`146.59.87.168:3000` / `git.tx1138.com` - [ ] 🟠 **Registries over HTTPS in the image too**`146.59.87.168:3000`
are baked `insecure=true`/`tls_verify:false` (`:216`, `:2308`). (Ties to §A.) are baked `insecure=true`/`tls_verify:false` (`:216`, `:2308`). (Ties to §A.)
- [ ] 🟡 **Add `unattended-upgrades` + a default-deny nftables firewall** (allow 22/80/443 + - [ ] 🟡 **Add `unattended-upgrades` + a default-deny nftables firewall** (allow 22/80/443 +
mesh/WG). Neither exists today; OS packages drift until reflash and there is no host mesh/WG). Neither exists today; OS packages drift until reflash and there is no host

View File

@ -842,7 +842,7 @@
</ul> </ul>
<h4>Registry</h4> <h4>Registry</h4>
<ul> <ul>
<li>Private registry at <code>git.tx1138.com/lfg2025/</code></li> <li>Private registry at <code>146.59.87.168:3000/lfg2025/</code></li>
<li>HTTPS (self-hosted Gitea)</li> <li>HTTPS (self-hosted Gitea)</li>
<li>All images pre-pulled into registry; nodes pull on first boot</li> <li>All images pre-pulled into registry; nodes pull on first boot</li>
</ul> </ul>

View File

@ -65,7 +65,7 @@ origin":
### IndeeHub (the streaming target) ### IndeeHub (the streaming target)
- Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/` - Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/`
(Vue 3 + NestJS). Submodule `git.tx1138.com/lfg2025/indeehub.git` (host retired (Vue 3 + NestJS). Submodule `146.59.87.168:3000/lfg2025/indeehub.git` (repointed off the retired host
needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls
`146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis, `146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis,
minio, nostr-rs-relay). minio, nostr-rs-relay).

View File

@ -112,7 +112,7 @@ jobs:
run: | run: |
sudo mkdir -p /etc/containers/registries.conf.d sudo mkdir -p /etc/containers/registries.conf.d
echo '[[registry]] echo '[[registry]]
location = "git.tx1138.com" location = "146.59.87.168:3000"
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
- name: Build unbundled ISO - name: Build unbundled ISO

View File

@ -221,7 +221,7 @@ location = "146.59.87.168:3000"
insecure = true insecure = true
[[registry]] [[registry]]
location = "git.tx1138.com" location = "146.59.87.168:3000"
insecure = true insecure = true
REGCONF REGCONF
fi fi
@ -1158,7 +1158,7 @@ fi
# If built against a newer GLIBC, the binary will fail at runtime. # If built against a newer GLIBC, the binary will fail at runtime.
# Rebuild with: FROM debian:13 AS builder # Rebuild with: FROM debian:13 AS builder
echo " Extracting NostrVPN binary..." echo " Extracting NostrVPN binary..."
_NVPN_IMG="${NOSTR_VPN_IMAGE:-git.tx1138.com/lfg2025/nostr-vpn:v0.3.7}" _NVPN_IMG="${NOSTR_VPN_IMAGE:-146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7}"
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)" NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
if [ -z "$NVPN_IMAGE_ID" ]; then if [ -z "$NVPN_IMAGE_ID" ]; then
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true $CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
@ -1189,11 +1189,11 @@ fi
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling) # Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
echo " Extracting nostr-rs-relay binary..." echo " Extracting nostr-rs-relay binary..."
RELAY_IMAGE="$($CONTAINER_CMD images -q git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)" RELAY_IMAGE="$($CONTAINER_CMD images -q 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)"
if [ -z "$RELAY_IMAGE" ]; then if [ -z "$RELAY_IMAGE" ]; then
$CONTAINER_CMD pull git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true $CONTAINER_CMD pull 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true
fi fi
RELAY_CONTAINER=$($CONTAINER_CMD create git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
if [ -n "$RELAY_CONTAINER" ]; then if [ -n "$RELAY_CONTAINER" ]; then
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \ $CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \ chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
@ -2319,7 +2319,7 @@ location = "146.59.87.168:3000"
insecure = true insecure = true
[[registry]] [[registry]]
location = "git.tx1138.com" location = "146.59.87.168:3000"
insecure = true insecure = true
REGCONF REGCONF
chown -R 1000:1000 /mnt/target/home/archipelago/.config chown -R 1000:1000 /mnt/target/home/archipelago/.config
@ -2330,7 +2330,7 @@ cat > /mnt/target/var/lib/archipelago/config/registries.json <<'DYNREG'
{ {
"registries": [ "registries": [
{"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0}, {"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0},
{"url": "git.tx1138.com/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10} {"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10}
] ]
} }
DYNREG DYNREG
@ -2475,7 +2475,7 @@ if [ -d "$REPO_DIR/.git" ]; then
exit 0 # Already cloned exit 0 # Already cloned
fi fi
echo "[update] Cloning Archipelago repo for self-updates..." echo "[update] Cloning Archipelago repo for self-updates..."
su - archipelago -c "git clone https://git.tx1138.com/lfg2025/archy $REPO_DIR" 2>/dev/null || { su - archipelago -c "git clone https://146.59.87.168:3000/lfg2025/archy $REPO_DIR" 2>/dev/null || {
echo "[update] Git clone failed (network?). Updates will retry on next boot." echo "[update] Git clone failed (network?). Updates will retry on next boot."
exit 0 exit 0
} }

View File

@ -40,10 +40,10 @@ EOF
# Configure registries (use Docker Hub and quay.io) # Configure registries (use Docker Hub and quay.io)
mkdir -p /home/archipelago/.config/containers/registries.conf.d mkdir -p /home/archipelago/.config/containers/registries.conf.d
cat > /home/archipelago/.config/containers/registries.conf <<EOF cat > /home/archipelago/.config/containers/registries.conf <<EOF
unqualified-search-registries = ["docker.io", "ghcr.io", "quay.io", "git.tx1138.com"] unqualified-search-registries = ["docker.io", "ghcr.io", "quay.io", "146.59.87.168:3000"]
[[registry]] [[registry]]
location = "git.tx1138.com" location = "146.59.87.168:3000"
insecure = true insecure = true
EOF EOF

View File

@ -172,7 +172,7 @@
"author": "File Browser", "author": "File Browser",
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0", "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser", "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png", "icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint", "author": "Fedimint",
"category": "money", "category": "money",
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0", "dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint", "repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [

View File

@ -348,7 +348,7 @@
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65"> <div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
<p class="font-medium text-white/80 mb-2">Easy sources</p> <p class="font-medium text-white/80 mb-2">Easy sources</p>
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p> <p>Use images from Docker Hub, GHCR, the VPS2 Gitea registry (146.59.87.168:3000), or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
</div> </div>
<div class="mt-5 flex gap-3"> <div class="mt-5 flex gap-3">

View File

@ -24,7 +24,7 @@ const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
/** Catalog URLs tried in order. First success wins. /** Catalog URLs tried in order. First success wins.
* Primary is the backend proxy (`/api/app-catalog`) server-side fetch * Primary is the backend proxy (`/api/app-catalog`) server-side fetch
* bypasses CORS on git.tx1138.com and CSP restrictions on the IP-port * bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
* fallback. If the backend is offline (mid-restart etc.) we fall back * fallback. If the backend is offline (mid-restart etc.) we fall back
* to the static copy baked into the frontend build. */ * to the static copy baked into the frontend build. */
const CATALOG_URLS = [ const CATALOG_URLS = [

View File

@ -1405,7 +1405,7 @@ init()
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p> <p>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p>
<p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p> <p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p>
<p>Update check itself retries on slow responses. If git.tx1138.com is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p> <p>Update check itself retries on slow responses. If the release server is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p>
</div> </div>
</div> </div>
<!-- v1.7.14-alpha --> <!-- v1.7.14-alpha -->
@ -1591,7 +1591,7 @@ init()
<span class="text-xs text-white/40">Apr 11, 2026</span> <span class="text-xs text-white/40">Apr 11, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Migrated container registry to git.tx1138.com</p> <p>Migrated container registry to the self-hosted Gitea</p>
</div> </div>
</div> </div>
<!-- v1.3.4 --> <!-- v1.3.4 -->

File diff suppressed because it is too large Load Diff

View File

@ -78,7 +78,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^electrumx$'; then
-e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \ -e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \
-e COIN=Bitcoin -e DB_DIRECTORY=/data \ -e COIN=Bitcoin -e DB_DIRECTORY=/data \
-e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \ -e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \
"${ELECTRUMX_IMAGE:-git.tx1138.com/lfg2025/electrumx:v1.18.0}" "${ELECTRUMX_IMAGE:-146.59.87.168:3000/lfg2025/electrumx:v1.18.0}"
fi fi
# Mempool API # Mempool API
@ -98,7 +98,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; th
-e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \ -e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \
-e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \ -e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \
-e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \ -e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \
"${MEMPOOL_API_IMAGE:-git.tx1138.com/lfg2025/mempool-api:v3.2.0}" "${MEMPOOL_API_IMAGE:-146.59.87.168:3000/lfg2025/mempool-api:v3.2.0}"
fi fi
# Stop Tor tunnel if it was active # Stop Tor tunnel if it was active

View File

@ -16,6 +16,9 @@
# 7. Containers stuck with exit code 127 (binary not found) # 7. Containers stuck with exit code 127 (binary not found)
# 8. Stopped core containers (rootless restart policy workaround) # 8. Stopped core containers (rootless restart policy workaround)
# 9. Missing rootless port listeners while Podman still shows published ports # 9. Missing rootless port listeners while Podman still shows published ports
# 10. Nginx Proxy Manager public hosts not mirrored into host nginx
# 11. BTCPay stores producing unpayable Lightning invoices (route hints off)
# 12. Missing catatonit (Podman init binary) — init-enabled deploys fail
# #
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always). # Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
# #
@ -383,10 +386,22 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
# ── Fix 8: Rootless netns egress lost ──────────────────────── # ── Fix 8: Rootless netns egress lost ────────────────────────
# Rootless podman uses pasta to give containers internet egress. If pasta's # Rootless podman uses pasta to give containers internet egress. If pasta's
# tap vanishes (host link flap, mount churn), the rootless-netns keeps inter- # tap vanishes (host link flap, mount churn, pasta dying during a boot-time
# container traffic working but silently loses outbound. Bitcoin IBD stalls # restart storm), the rootless-netns keeps inter-container traffic working
# at 0 peers; package pulls fail. The only reliable repair is a stop-all/ # but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
# start-all cycle so pasta + aardvark-dns rebuild the netns from scratch. # fail. The repair must rebuild the netns from scratch: merely cycling the
# containers reuses the existing (broken) netns because its holders
# (aardvark-dns, podman's pause process) survive — observed on shorty-s
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
# every timer run for ~an hour without ever restoring egress. So: stop the
# containers, kill the netns holders, `podman system migrate`, clear the
# stale netns state, then start everything back up.
#
# Destructive-action latch: cycling the whole fleet is a last resort. After
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
# until a run observes egress healthy again, which resets the counter.
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
NETNS_CYCLE_MAX=3
fix_rootless_netns_egress() { fix_rootless_netns_egress() {
# Needs root for nsenter. When doctor runs as the rootless container owner, # Needs root for nsenter. When doctor runs as the rootless container owner,
# a failed nsenter probe is a permissions artifact, not evidence of broken # a failed nsenter probe is a permissions artifact, not evidence of broken
@ -410,16 +425,28 @@ fix_rootless_netns_egress() {
# Probe egress from inside the rootless-netns. One probe is noisy; # Probe egress from inside the rootless-netns. One probe is noisy;
# require two consecutive failures 10s apart to rule out transients. # require two consecutive failures 10s apart to rule out transients.
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
return 1 # first probe succeeded return 1 # first probe succeeded
fi fi
sleep 10 sleep 10
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1) aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1 [ -z "$aardvark_pid" ] && return 1
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE"
return 1 # recovered on its own return 1 # recovered on its own
fi fi
log "Rootless-netns egress is broken (host online, container netns unreachable) — cycling" # Latch: don't keep bouncing the fleet when the rebuild demonstrably
# isn't fixing it.
local failures
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
case "$failures" in *[!0-9]*|"") failures=0;; esac
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
return 1
fi
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman" local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
local running local running
@ -435,6 +462,19 @@ fix_rootless_netns_egress() {
$PODMANCMD stop --all --time 30 >/dev/null 2>&1 $PODMANCMD stop --all --time 30 >/dev/null 2>&1
sleep 5 sleep 5
# Tear the broken netns down for real: kill its holders and drop the
# stale state so the first container start rebuilds pasta + aardvark-dns
# from scratch. Without this, podman re-enters the old netns and the
# missing pasta tap never comes back.
log " Rebuilding rootless netns (killing holders, clearing state)..."
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
pkill -U "$archi_uid" -x pasta 2>/dev/null
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
sleep 2
$PODMANCMD system migrate >/dev/null 2>&1
rm -rf "/run/user/$archi_uid/containers/networks"
log " Starting containers back up..." log " Starting containers back up..."
for c in $running; do for c in $running; do
$PODMANCMD start "$c" >/dev/null 2>&1 & $PODMANCMD start "$c" >/dev/null 2>&1 &
@ -445,8 +485,11 @@ fix_rootless_netns_egress() {
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1) aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log " Rootless-netns egress restored ($count containers cycled)" log " Rootless-netns egress restored ($count containers cycled)"
rm -f "$NETNS_CYCLE_STATE"
else else
log " WARN: egress still broken after cycle — may need manual intervention" failures=$((failures + 1))
echo "$failures" > "$NETNS_CYCLE_STATE"
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
fi fi
return 0 return 0
} }
@ -526,6 +569,51 @@ fix_npm_public_hosts() {
return 1 return 1
} }
# ── Fix 12: BTCPay Lightning route hints ─────────────────────
# A BTCPay store whose LND node has only private (unannounced) channels
# produces BOLT11 invoices that external wallets cannot route to unless the
# store's lightningPrivateRouteHints flag is on — payers see "no way to pay
# this invoice" (observed on shorty-s 2026-07-10 with a Blink payer). Route
# hints are a no-op with public channels and essential with private ones, so
# the doctor enforces the flag on every store. BTCPay reads store blobs from
# Postgres per request; no restart needed.
fix_btcpay_route_hints() {
local state
state=$(podman_rootless inspect archy-btcpay-db --format '{{.State.Status}}' 2>/dev/null || echo "missing")
[ "$state" = "running" ] || return 1
local count
count=$(podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -t -A -c \
"SELECT count(*) FROM \"Stores\" WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" 2>/dev/null)
[ -n "$count" ] && [ "$count" -gt 0 ] 2>/dev/null || return 1
if podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -q -c \
"UPDATE \"Stores\" SET \"StoreBlob\" = jsonb_set(\"StoreBlob\", '{lightningPrivateRouteHints}', 'true'::jsonb) WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" >/dev/null 2>&1; then
log "Enabled Lightning route hints on $count BTCPay store(s) (private-channel invoices were unpayable)"
return 0
fi
return 1
}
# ── Fix 13: Missing catatonit (container init binary) ────────
# Podman resolves `--init` (and any Portainer/compose deploy with
# "init: true") through catatonit; Debian's podman package only
# Recommends it, so a node installed or upgraded without it fails those
# deploys with a missing-init error (observed on shorty-s 2026-07-10
# deploying sites via Portainer). install-podman.sh covers fresh ISO
# installs; this heals nodes that predate it.
fix_missing_catatonit() {
command -v catatonit >/dev/null 2>&1 && return 1
command -v apt-get >/dev/null 2>&1 || return 1
if DEBIAN_FRONTEND=noninteractive apt-get install -y catatonit >/dev/null 2>&1; then
log "Installed catatonit (init-enabled container deploys were failing)"
return 0
fi
log "WARNING: catatonit missing and apt-get install failed — init-enabled deploys will fail"
return 1
}
# ── Main ───────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────
# If remote host provided, run via SSH # If remote host provided, run via SSH
@ -556,6 +644,8 @@ run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports run_fix "rootless-ports" fix_missing_rootless_ports
run_fix "npm-public-hosts" fix_npm_public_hosts run_fix "npm-public-hosts" fix_npm_public_hosts
run_fix "btcpay-route-hints" fix_btcpay_route_hints
run_fix "catatonit" fix_missing_catatonit
echo "" echo ""
if [ $FIXES_APPLIED -gt 0 ]; then if [ $FIXES_APPLIED -gt 0 ]; then

View File

@ -74,7 +74,7 @@ mkdir -p "$BUILD_DIR"
# Create Dockerfile # Create Dockerfile
cat > "$BUILD_DIR/Dockerfile" << 'EOF' cat > "$BUILD_DIR/Dockerfile" << 'EOF'
FROM ${NGINX_ALPINE_IMAGE:-git.tx1138.com/lfg2025/nginx:1.29.6-alpine} FROM ${NGINX_ALPINE_IMAGE:-146.59.87.168:3000/lfg2025/nginx:1.29.6-alpine}
# Copy the static UI # Copy the static UI
COPY index.html /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/

View File

@ -146,7 +146,7 @@ run_smoke_tests() {
# Test 3: Install a lightweight container (filebrowser — small, fast, no deps) # Test 3: Install a lightweight container (filebrowser — small, fast, no deps)
TESTS=$((TESTS + 1)) TESTS=$((TESTS + 1))
local install_img="git.tx1138.com/lfg2025/filebrowser:v2.27.0" local install_img="146.59.87.168:3000/lfg2025/filebrowser:v2.27.0"
# Check if already installed # Check if already installed
local fb_state local fb_state
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'") fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")

View File

@ -9,7 +9,7 @@
# #
# Image versions: sourced from /opt/archipelago/image-versions.sh (single source of truth). # Image versions: sourced from /opt/archipelago/image-versions.sh (single source of truth).
# All container image references use the $*_IMAGE variables defined there. # All container image references use the $*_IMAGE variables defined there.
# Images pull from the Archipelago app registry (git.tx1138.com/lfg2025/). # Images pull from the Archipelago app registry (146.59.87.168:3000/lfg2025/).
# #
# --- PLANNED REFACTOR (post-beta) --- # --- PLANNED REFACTOR (post-beta) ---
# This script is ~995 lines and should be split into a modular library. # This script is ~995 lines and should be split into a modular library.

View File

@ -11,7 +11,8 @@
# Archipelago app registries (primary + fallback) # Archipelago app registries (primary + fallback)
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025" ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
ARCHY_REGISTRY_FALLBACK="git.tx1138.com/lfg2025" # No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
ARCHY_REGISTRY_FALLBACK=""
# Bitcoin stack # Bitcoin stack
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest" BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"

View File

@ -304,7 +304,7 @@ resolve_spec_image() {
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \ "${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \ "80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do "80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
[ "$candidate" = "/" ] && continue case "$candidate" in /*) continue;; esac
if image_exists "$candidate"; then if image_exists "$candidate"; then
info "$SPEC_NAME — using local image alias $candidate" info "$SPEC_NAME — using local image alias $candidate"
SPEC_IMAGE="$candidate" SPEC_IMAGE="$candidate"

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Self-update: pull latest code from git.tx1138.com and apply # Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) and apply
# Designed to run on installed Archipelago nodes (as archipelago user) # Designed to run on installed Archipelago nodes (as archipelago user)
# #
# Usage: # Usage:
@ -8,7 +8,7 @@
# ./self-update.sh --force # Apply even if already up to date # ./self-update.sh --force # Apply even if already up to date
# #
# The script: # The script:
# 1. Pulls latest code from origin (git.tx1138.com) # 1. Pulls latest code from origin (146.59.87.168:3000)
# 2. Builds the Rust backend (release mode) # 2. Builds the Rust backend (release mode)
# 3. Builds the Vue frontend (production mode) # 3. Builds the Vue frontend (production mode)
# 4. Installs the new binary and web UI # 4. Installs the new binary and web UI
@ -69,7 +69,7 @@ done
# Ensure repo exists # Ensure repo exists
if [ ! -d "$REPO_DIR/.git" ]; then if [ ! -d "$REPO_DIR/.git" ]; then
err "Repo not found at $REPO_DIR" err "Repo not found at $REPO_DIR"
err "Clone it first: git clone https://git.tx1138.com/lfg2025/archy ~/archy" err "Clone it first: git clone http://146.59.87.168:3000/lfg2025/archy ~/archy"
exit 1 exit 1
fi fi

View File

@ -86,7 +86,7 @@ else
# Check trusted registry # Check trusted registry
TRUSTED=false TRUSTED=false
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "git.tx1138.com"; do for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
if echo "$IMAGE" | grep -q "$reg"; then if echo "$IMAGE" | grep -q "$reg"; then
TRUSTED=true TRUSTED=true
break break