feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony

- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
  fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
  decoupled-update wiring; Fedimint Client core app in catalog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 19:21:07 -04:00
co-authored by Claude Opus 4.8
parent c10f2ac22e
commit bd567cd165
34 changed files with 2677 additions and 68 deletions
@@ -281,6 +281,109 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
))
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container.
///
/// For each writable bind mount, write-probe as the container's own process
/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec`
/// as root) to that service uid:gid. Because the chown runs in the container's
/// user namespace, podman translates it to the correct host owner regardless of
/// the rootless idmap — so there is NO host-side UID guessing, and it works for
/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps.
/// This is the durable replacement for the per-app hardcoded host chowns.
///
/// Drift-checked via the write-probe, so it only `chown`s when the volume is
/// actually unwritable — cheap enough to call on every reconcile. Best-effort:
/// returns true if it repaired something; never fails reconcile (a degraded app
/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17).
async fn ensure_running_container_ownership(name: &str) -> bool {
async fn podman_stdout(args: &[&str]) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(args)
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
// The uid:gid the container's main process actually runs as.
let uid = match podman_stdout(&["exec", name, "id", "-u"]).await {
Some(u) if !u.is_empty() => u,
_ => return false, // can't exec (no shell / not running) — nothing to do
};
let gid = podman_stdout(&["exec", name, "id", "-g"])
.await
.filter(|g| !g.is_empty())
.unwrap_or_else(|| uid.clone());
// Writable bind-mount destinations only.
let dests = match podman_stdout(&[
"inspect",
name,
"--format",
"{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}",
])
.await
{
Some(d) => d,
None => return false,
};
let mut repaired = false;
for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) {
// Never touch system / socket bind mounts.
if dest == "/"
|| dest.starts_with("/proc")
|| dest.starts_with("/sys")
|| dest.starts_with("/dev")
|| dest.starts_with("/run")
|| dest.starts_with("/etc")
|| dest.ends_with(".sock")
{
continue;
}
// Drift check: can the service user write here already?
let probe = format!(
"t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null"
);
let writable = tokio::process::Command::new("podman")
.args(["exec", name, "sh", "-c", &probe])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if writable {
continue;
}
// Repair inside the container's userns — podman maps to the right host uid.
let chown = tokio::process::Command::new("podman")
.args(["exec", "-u", "0", name, "chown", "-R", &format!("{uid}:{gid}"), dest])
.output()
.await;
match chown {
Ok(o) if o.status.success() => {
repaired = true;
tracing::warn!(
container = %name, dest, uid = %uid,
"repaired unwritable volume ownership (in-container chown)"
);
}
Ok(o) => tracing::warn!(
container = %name, dest,
"volume ownership repair failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}"),
}
}
repaired
}
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
@@ -1155,6 +1258,30 @@ impl ProdContainerOrchestrator {
}
}
}
// App-agnostic volume-ownership self-heal. Sweep EVERY running container
// (registry/manifest apps AND legacy compose stacks like immich) and
// repair any that can't write their bind mounts — the durable, app-
// agnostic replacement for per-app hardcoded host chowns. Drift-checked,
// so steady state is just cheap in-container write-probes; only a broken
// volume is chowned (in-userns, mapping-proof) and its container
// restarted to recover. Fixes the class of EACCES crash-loops fleet-wide
// and self-heals existing nodes after OTA. (immich .198, 2026-06-17.)
if let Ok(containers) = self.runtime.list_containers().await {
for c in containers
.iter()
.filter(|c| matches!(c.state, ContainerState::Running))
{
if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman")
.args(["restart", &c.name])
.output()
.await;
}
}
}
report
}
@@ -1180,6 +1307,33 @@ impl ProdContainerOrchestrator {
let _guard = lock.lock().await;
self.ensure_app_secrets(&app_id).await?;
// Don't fight the Bitcoin-implementation switch: bitcoin-core and
// bitcoin-knots share port 8332, so if the *other* variant is already
// running the inactive one can never start — the reconciler would just
// churn "address already in use" and report a reconcile failure. Skip
// it, mirroring the health monitor's same skip. (#47)
if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) {
"bitcoin-core" => Some("bitcoin-knots"),
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
_ => None,
} {
if let Ok(list) = self.runtime.list_containers().await {
let other_running = list.iter().any(|c| {
c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict
&& matches!(c.state, ContainerState::Running)
});
if other_running {
tracing::debug!(
app_id = %app_id,
conflict,
"skipping reconcile — the other Bitcoin implementation is running"
);
return Ok(ReconcileAction::NoOp);
}
}
}
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let name = compute_container_name(&lm.manifest);