merge: bring the open-source readiness work onto the phase-13 branch

Merges gitea-ai/main (65 commits) into the phase-13 branch (419) so one
build carries both lines — the AIUI/assistant/container work and the
open-source readiness work (licensing, the marketplace DID signature layer,
the registry domain migration, the secrets and infrastructure scrub).

Every Rust file auto-merged. The container fixes from this branch and main's
registry-domain migration and node-name genericisation coexist without
manual intervention.

Conflict resolution — all of them were modify/delete, and all were resolved
in main's favour deliberately:

`.planning/**`, `scripts/deploy-to-target.sh` and `scripts/setup-aiui-server.sh`
were deleted by main's `6ba05996` ("security: remove all infrastructure and
internal process material from the repo") and added to .gitignore there.
Keeping this branch's copies would have re-committed internal process and
infrastructure material into a repo being prepared for publication, silently
undoing that cleanup. Resolved with `git rm --cached`, so every file remains
on disk locally and in this branch's history — it is untracked, not lost.
The remaining .planning files this branch added after the merge base were
untracked the same way, so the result is consistent rather than half-tracked.

Container suite 221/221 on the merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 09:02:32 -04:00
co-authored by Claude Opus 5
542 changed files with 5638 additions and 83054 deletions
+1 -8
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.125-alpha"
version = "1.7.126-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@@ -172,7 +172,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zbase32",
"zeroize",
"zip",
]
@@ -6855,12 +6854,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbase32"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f"
[[package]]
name = "zerocopy"
version = "0.8.33"
+6
View File
@@ -9,6 +9,12 @@ members = [
"security",
]
# Shared package metadata, inherited by each member via `license.workspace = true`.
# The repo ships an MIT LICENSE and the README advertises MIT; this makes the
# crates themselves declare it so `cargo metadata`, packaging and mirrors agree.
[workspace.package]
license = "MIT"
# Profiles at workspace root (members' [profile] are ignored in virtual workspaces)
[profile.release]
opt-level = 3
+5 -2
View File
@@ -1,7 +1,8 @@
[package]
name = "archipelago"
version = "1.7.125-alpha"
version = "1.7.126-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -108,8 +109,10 @@ data-encoding = "2.6"
zeroize = { version = "1.8.2", features = ["derive"] }
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
# z-base-32 is implemented in-tree at src/network/zbase32.rs: the `zbase32`
# crate is LGPL-3.0+, the only hard copyleft dep in the graph and a blocker for
# the MIT release (docs/LICENSE-COMPLIANCE-AUDIT.md §2).
mainline = "2"
zbase32 = "0.1"
bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
+2 -2
View File
@@ -153,7 +153,7 @@ impl ApiHandler {
}
if upstreams.is_empty() {
upstreams.push(
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
"https://source.archipelago-foundation.org/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
}
@@ -654,7 +654,7 @@ impl ApiHandler {
// proxy INTO it, it binds 0.0.0.0, and it is explicitly on the
// fips0 mesh allowlist (fips/app_ports.rs). So an unauthenticated
// GET from any mesh peer, LAN host or Tailscale peer returned the
// admin macaroon. Verified live on archi-dev-box 2026-08-02.
// admin macaroon. Verified live on a test node 2026-08-02.
//
// The lesson generalises: an auth check performed by one reverse
// proxy is not an auth check, because it only holds for traffic that
@@ -146,6 +146,12 @@ impl RpcHandler {
"lnd.seed-backup-status" => self.handle_lnd_seed_backup_status().await,
"lnd.seed-reveal" => self.handle_lnd_seed_reveal(params).await,
"lnd.seed-backup-ack" => self.handle_lnd_seed_backup_ack().await,
// Lightning credential rotation. `rotate` re-verifies the node
// password and returns immediately; the work runs in the
// background and the UI polls `-progress`.
"lnd.macaroon-status" => self.handle_lnd_macaroon_status().await,
"lnd.rotate-macaroons" => self.handle_lnd_rotate_macaroons(params).await,
"lnd.macaroon-rotation-progress" => self.handle_lnd_macaroon_rotation_progress().await,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
+1 -1
View File
@@ -219,7 +219,7 @@ impl RpcHandler {
anyhow::bail!("npub must be bech32 (npub1...)");
}
if !anchor.address.contains(':') {
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
anyhow::bail!("address must be host:port (e.g. 192.0.2.12:8668)");
}
let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
// Push just the newly-added anchor into the running daemon so
@@ -0,0 +1,831 @@
//! LND macaroon rotation, driven from the dashboard.
//!
//! A macaroon is a bearer token: whoever holds it can spend from this node's
//! Lightning wallet. Anything that ever read one — a leaked endpoint, a shared
//! screenshot, a paired phone that has since been lost, a BTCPay instance that
//! ran a version with a published vulnerability — keeps that ability until the
//! macaroons are rotated. Rotation is therefore a routine operator action, and
//! before this module the only way to perform it was to SSH into the node and
//! run `scripts/security/rotate-lnd-macaroon.sh` by hand.
//!
//! ## What rotation actually does
//!
//! LND derives every macaroon it issues from a root key in `macaroons.db`.
//! Remove that root key and the issued macaroon files, restart, and LND mints a
//! fresh root key and a fresh set of macaroons on unlock. Every previously
//! issued macaroon — including any an attacker holds — stops verifying.
//!
//! ## Why funds and channels survive
//!
//! Macaroons are bearer tokens, not keys. Coins live in `wallet.db` and channel
//! state in `channel.db`; channels are secured by the node's identity and
//! channel keys, none of which derive from the macaroon root key. This code
//! never opens, moves or deletes either database. What it does instead is
//! *prove* they survived: it records the node's identity pubkey and channel
//! census before rotating and refuses to report success if either changed.
//!
//! Deliberately NOT asserted: that `wallet.db` is byte-identical. btcwallet
//! records chain-sync progress inside it, so the file legitimately changes on
//! every start — asserting byte-identity would fire a frightening false alarm
//! on a completely healthy rotation.
//!
//! ## What it never does
//!
//! No macaroon *content* is read into a response, an error, a log line or the
//! progress feed the UI polls. Everything reported is a SHA-256 digest or a
//! byte count — enough to prove the material changed without disclosing it to
//! whoever is looking at the screen.
//!
//! It also cannot reach LND's destructive wallet-recovery path: the restart
//! unlocks via `unlock_existing_wallet_no_wipe`, so a wallet whose password
//! this node does not hold surfaces as a failed rotation, never as a wipe.
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use serde::Serialize;
use std::sync::{Arc, Mutex, OnceLock};
use super::LND_REST_BASE_URL;
/// LND's mainnet macaroon directory. 0700 and owned by the container's mapped
/// uid, so every read/write below goes through `sudo -n`.
const LND_MAINNET_DIR: &str = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet";
/// Quadlet service for the core LND app. Older nodes run LND as a plain podman
/// container with no unit; `restart_lnd` handles both.
const LND_SERVICE: &str = "lnd.service";
const LND_CONTAINER: &str = "lnd";
/// Where the orchestrator materialises app secrets. Hardcoded to match
/// `prod_orchestrator`'s own default rather than derived from `config.data_dir`
/// — writing the BTCPay connection string anywhere the orchestrator does not
/// read it would be worse than not writing it at all, because it would look
/// like it worked.
const SECRETS_DIR: &str = "/var/lib/archipelago/secrets";
/// Longest we wait for LND to mint a fresh `admin.macaroon` after the restart.
/// Generous on purpose: LND opens its databases before serving anything, which
/// is minutes on a loaded node.
const MACAROON_WAIT_SECS: u64 = 900;
// ── Progress the UI polls ────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
enum StepState {
Pending,
Running,
Done,
Failed,
/// Ran, decided there was nothing to do, and said so. Distinct from `Done`
/// so "BTCPay has no internal Lightning node" never reads as "BTCPay was
/// reconnected".
Skipped,
}
#[derive(Debug, Clone, Serialize)]
struct RotationStep {
key: &'static str,
label: &'static str,
state: StepState,
detail: Option<String>,
}
/// Ordered because the UI renders it as a checklist and an operator watching a
/// credential rotation should be able to see exactly how far it got.
const STEPS: &[(&str, &str)] = &[
(
"preflight",
"Check LND is healthy and record what must survive",
),
("backup", "Back up the current macaroon material"),
("stop", "Stop Lightning"),
("remove", "Remove the old root key and issued macaroons"),
("start", "Start Lightning and unlock the wallet"),
("verify", "Confirm the node and its channels are unchanged"),
("btcpay", "Reconnect BTCPay Server to the new credentials"),
];
#[derive(Debug, Clone, Serialize)]
pub(crate) struct RotationProgress {
running: bool,
/// `None` while running, then the verdict. Split from `running` so the UI
/// can tell "in progress" from "finished and failed".
ok: Option<bool>,
started_at: Option<String>,
finished_at: Option<String>,
error: Option<String>,
steps: Vec<RotationStep>,
/// Where the old material was copied. Still secret — it is the old root key
/// — so the UI tells the operator to delete it once clients are re-paired.
backup_path: Option<String>,
identity_pubkey: Option<String>,
channels_before: Option<u32>,
channels_after: Option<u32>,
/// Digest of the freshly minted admin macaroon. A digest, never the token.
new_admin_macaroon_sha256: Option<String>,
}
impl Default for RotationProgress {
fn default() -> Self {
Self {
running: false,
ok: None,
started_at: None,
finished_at: None,
error: None,
steps: STEPS
.iter()
.map(|(key, label)| RotationStep {
key,
label,
state: StepState::Pending,
detail: None,
})
.collect(),
backup_path: None,
identity_pubkey: None,
channels_before: None,
channels_after: None,
new_admin_macaroon_sha256: None,
}
}
}
impl RotationProgress {
fn set(&mut self, key: &str, state: StepState, detail: Option<String>) {
if let Some(step) = self.steps.iter_mut().find(|s| s.key == key) {
step.state = state;
if detail.is_some() {
step.detail = detail;
}
}
}
}
/// One rotation at a time, process-wide. Two concurrent rotations would race on
/// the same files with LND stopped underneath them.
fn progress() -> &'static Mutex<RotationProgress> {
static PROGRESS: OnceLock<Mutex<RotationProgress>> = OnceLock::new();
PROGRESS.get_or_init(|| Mutex::new(RotationProgress::default()))
}
fn with_progress<F: FnOnce(&mut RotationProgress)>(f: F) {
if let Ok(mut guard) = progress().lock() {
f(&mut guard);
}
}
fn snapshot() -> RotationProgress {
progress()
.lock()
.map(|g| g.clone())
.unwrap_or_else(|e| e.into_inner().clone())
}
// ── Host helpers ─────────────────────────────────────────────────────────────
/// `sudo -n <args>`, capturing output. Non-interactive: a node whose sudoers
/// does not permit this fails loudly here rather than hanging on a prompt.
async fn sudo(args: &[&str]) -> Result<std::process::Output> {
let mut cmd = tokio::process::Command::new("sudo");
cmd.arg("-n").args(args);
cmd.output()
.await
.with_context(|| format!("sudo -n {}", args.join(" ")))
}
async fn sudo_ok(args: &[&str]) -> Result<()> {
let out = sudo(args).await?;
if !out.status.success() {
anyhow::bail!(
"sudo {} exited {}: {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// SHA-256 of a root-owned file, or `None` if it isn't there. Only ever the
/// digest — the file's bytes never enter this process.
async fn digest_as_root(path: &str) -> Option<String> {
let out = sudo(&["sha256sum", path]).await.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.map(str::to_string)
}
/// Every file rotation replaces: the issued macaroons plus the root key they
/// derive from.
///
/// Enumerated with `sudo find` rather than a shell glob for a reason worth
/// keeping: the directory is 0700 owned by the container's mapped uid, so a
/// glob evaluated by this (unprivileged) process expands to nothing. It would
/// silently make both the backup and the removal no-ops while every surrounding
/// step still reported success.
async fn macaroon_files() -> Result<Vec<String>> {
let out = sudo(&[
"find",
LND_MAINNET_DIR,
"-maxdepth",
"1",
"(",
"-name",
"*.macaroon",
"-o",
"-name",
"macaroons.db",
")",
])
.await?;
if !out.status.success() {
anyhow::bail!(
"listing macaroon material failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
/// True when LND is managed by a generated Quadlet unit on this node. Nodes
/// predating the Quadlet migration run a bare podman container instead, and
/// stopping the wrong way there means either a no-op or an orphan.
async fn lnd_has_quadlet_unit() -> bool {
crate::container::quadlet::is_active(LND_SERVICE).await
|| tokio::process::Command::new("systemctl")
.args(["--user", "cat", LND_SERVICE])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false)
}
async fn stop_lnd() -> Result<()> {
if lnd_has_quadlet_unit().await {
return crate::container::quadlet::stop_service(LND_SERVICE)
.await
.context("stopping lnd.service");
}
podman_scoped(&["stop", LND_CONTAINER]).await
}
async fn start_lnd() -> Result<()> {
if lnd_has_quadlet_unit().await {
return crate::container::quadlet::enable_now(LND_SERVICE)
.await
.context("starting lnd.service");
}
podman_scoped(&["start", LND_CONTAINER]).await
}
/// `podman` inside a transient user scope, matching how the orchestrator and
/// health monitor drive rootless containers (keeps it out of the archipelago
/// service's cgroup, so an archipelago restart doesn't take LND with it).
async fn podman_scoped(args: &[&str]) -> Result<()> {
let out = tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args(args)
.output()
.await
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
if !out.status.success() {
anyhow::bail!(
"podman {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
// ── LND facts ────────────────────────────────────────────────────────────────
/// Identity and channel census — the two things that must be identical either
/// side of a rotation.
#[derive(Debug, Clone, Copy, Default)]
struct LndCensus {
channels_open: u32,
channels_pending: u32,
}
fn lnd_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
// LND serves its own self-signed cert on loopback; the macaroon, not
// the certificate, is what authenticates this call.
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")
}
/// `getinfo` using whatever macaroon is on disk right now. Returns the identity
/// pubkey and census, or an error describing why LND could not answer.
async fn read_census() -> Result<(String, LndCensus)> {
let macaroon = super::read_lnd_admin_macaroon()
.await
.context("reading LND admin macaroon")?;
let resp = lnd_client()?
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", hex::encode(&macaroon))
.send()
.await
.context("LND is not answering on its REST port")?;
let body: serde_json::Value = resp
.json()
.await
.context("LND returned a response that is not JSON")?;
let pubkey = body
.get("identity_pubkey")
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
anyhow::anyhow!(
"LND did not report an identity — it is most likely still starting or locked ({})",
body.get("message")
.and_then(|m| m.as_str())
.unwrap_or("no detail")
)
})?;
let num = |k: &str| body.get(k).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
Ok((
pubkey,
LndCensus {
// Active + inactive, summed deliberately. `num_active_channels`
// counts channels whose peer is currently online, so it legitimately
// dips for minutes after ANY restart while peers reconnect —
// asserting on it alone would abort a perfectly healthy rotation.
// The total number of channels held is the real safety property.
channels_open: num("num_active_channels") + num("num_inactive_channels"),
channels_pending: num("num_pending_channels"),
},
))
}
/// Poll until LND answers `getinfo` with a fresh macaroon, or the budget runs
/// out. Used after the restart, so "not ready yet" is the expected case for
/// most of the wait.
async fn wait_for_serving(deadline: std::time::Instant) -> Result<(String, LndCensus)> {
let mut last = String::from("LND did not become reachable");
while std::time::Instant::now() < deadline {
match read_census().await {
Ok(v) => return Ok(v),
Err(e) => last = format!("{e:#}"),
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
anyhow::bail!("timed out waiting for LND to serve again: {last}")
}
// ── Status ───────────────────────────────────────────────────────────────────
impl RpcHandler {
/// Read-only picture of this node's Lightning credentials: when they were
/// issued, what depends on them, and whether anything is already out of
/// step. Never returns macaroon content.
pub(in crate::api::rpc) async fn handle_lnd_macaroon_status(
&self,
) -> Result<serde_json::Value> {
let admin_path = format!("{LND_MAINNET_DIR}/admin.macaroon");
let installed = digest_as_root(&admin_path).await;
// `stat -c %y` on the macaroon is when LND last minted it, which is the
// one date an operator actually wants ("am I still carrying credentials
// from before that incident?").
let issued_at = match sudo(&["stat", "-c", "%y", &admin_path]).await {
Ok(out) if out.status.success() => Some(
String::from_utf8_lossy(&out.stdout)
.trim()
.chars()
.take(19)
.collect::<String>(),
),
_ => None,
};
let (identity_pubkey, census, lnd_error) = match read_census().await {
Ok((pk, c)) => (Some(pk), Some(c), None),
Err(e) => (None, None, Some(format!("{e:#}"))),
};
// Whether BTCPay's inline copy still matches. `None` = BTCPay has no
// internal Lightning node configured, which is a normal state and not a
// problem to report.
let btcpay_current = crate::container::lnd::btcpay_lnd_connection_is_current(
std::path::Path::new(SECRETS_DIR),
)
.await;
Ok(serde_json::json!({
"installed": installed.is_some(),
"admin_macaroon_sha256": installed,
"issued_at": issued_at,
"identity_pubkey": identity_pubkey,
"channels_open": census.map(|c| c.channels_open),
"channels_pending": census.map(|c| c.channels_pending),
"lnd_error": lnd_error,
"btcpay_uses_internal_lnd": btcpay_current.is_some(),
"btcpay_credential_current": btcpay_current,
"rotation": snapshot(),
}))
}
/// Start a rotation. Password-gated and asynchronous.
///
/// Password-gated because invalidating every credential a wallet app holds
/// is an operator action, and a session cookie only proves a browser was
/// once logged in — the same reasoning as `node.rotate-identity` and TOTP
/// setup, which both re-verify.
///
/// Asynchronous because the work takes minutes (LND's databases have to
/// close and reopen); the HTTP request returns immediately and the UI polls
/// `lnd.macaroon-rotation-progress`.
pub(in crate::api::rpc) async fn handle_lnd_rotate_macaroons(
self: &Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.unwrap_or("");
if password.is_empty() {
anyhow::bail!("Node password required to rotate Lightning credentials");
}
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Password verification failed");
}
// Claim the slot and publish a fresh feed in one critical section, so a
// second click cannot observe a half-reset progress object.
{
let mut guard = progress()
.lock()
.map_err(|_| anyhow::anyhow!("rotation state poisoned"))?;
if guard.running {
anyhow::bail!("A macaroon rotation is already running on this node");
}
*guard = RotationProgress {
running: true,
started_at: Some(chrono::Utc::now().to_rfc3339()),
..Default::default()
};
}
let orchestrator = self.orchestrator.clone();
tokio::spawn(async move {
let outcome = run_rotation(orchestrator).await;
with_progress(|p| {
p.running = false;
p.finished_at = Some(chrono::Utc::now().to_rfc3339());
match &outcome {
Ok(()) => p.ok = Some(true),
Err(e) => {
p.ok = Some(false);
p.error = Some(format!("{e:#}"));
}
}
});
match outcome {
Ok(()) => tracing::info!("LND macaroon rotation completed"),
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "LND macaroon rotation failed")
}
}
});
Ok(serde_json::json!({ "status": "started" }))
}
/// Poll the running (or last) rotation.
pub(in crate::api::rpc) async fn handle_lnd_macaroon_rotation_progress(
&self,
) -> Result<serde_json::Value> {
Ok(serde_json::to_value(snapshot())?)
}
}
// ── The rotation itself ──────────────────────────────────────────────────────
async fn run_rotation(
orchestrator: Option<Arc<dyn crate::container::ContainerOrchestrator>>,
) -> Result<()> {
// 1. Preflight — establish what must survive, while LND can still be asked.
with_progress(|p| p.set("preflight", StepState::Running, None));
let files = macaroon_files().await?;
if files.is_empty() {
with_progress(|p| p.set("preflight", StepState::Failed, None));
anyhow::bail!(
"no macaroon material found in {LND_MAINNET_DIR} — nothing to rotate, and \
restarting Lightning for no reason would be a pointless outage"
);
}
let (pubkey_before, census_before) = read_census().await.context(
"refusing to rotate: LND is not answering, so there would be no baseline to prove your \
channels survived. Start Lightning, wait for it to sync, and try again",
)?;
with_progress(|p| {
p.identity_pubkey = Some(pubkey_before.clone());
p.channels_before = Some(census_before.channels_open);
p.set(
"preflight",
StepState::Done,
Some(format!(
"{} channel(s) open, {} pending — these must be identical afterwards",
census_before.channels_open, census_before.channels_pending
)),
);
});
// 2. Back up, so a mistake is recoverable. Verified by count: a backup that
// silently copied nothing is the one failure that makes the deletion
// below unrecoverable.
with_progress(|p| p.set("backup", StepState::Running, None));
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let backup = format!("/var/lib/archipelago/lnd/macaroon-rotation-{stamp}");
sudo_ok(&["mkdir", "-p", &backup]).await?;
sudo_ok(&["chmod", "700", &backup]).await?;
for f in &files {
sudo_ok(&["cp", "-a", f, &backup])
.await
.with_context(|| format!("backing up {f} — aborting before any deletion"))?;
}
let backed_up = sudo(&["find", &backup, "-maxdepth", "1", "-type", "f"])
.await?
.stdout;
let backed_up = String::from_utf8_lossy(&backed_up)
.lines()
.filter(|l| !l.trim().is_empty())
.count();
if backed_up != files.len() {
with_progress(|p| p.set("backup", StepState::Failed, None));
anyhow::bail!(
"backup incomplete — {backed_up} of {} files in {backup}. Refusing to delete anything",
files.len()
);
}
with_progress(|p| {
p.backup_path = Some(backup.clone());
p.set(
"backup",
StepState::Done,
Some(format!("{backed_up} file(s) copied to {backup}")),
);
});
// 3. Stop.
with_progress(|p| p.set("stop", StepState::Running, None));
stop_lnd().await.context("stopping LND")?;
with_progress(|p| p.set("stop", StepState::Done, None));
// 4. Remove the credential material — and only now, with a verified backup.
with_progress(|p| p.set("remove", StepState::Running, None));
for f in &files {
sudo_ok(&["rm", "-f", f])
.await
.with_context(|| format!("removing {f} — restore from {backup}"))?;
}
with_progress(|p| {
p.set(
"remove",
StepState::Done,
Some(format!("{} file(s) removed", files.len())),
)
});
// 5. Start, and unlock. The unlock is explicit rather than left to the next
// reconcile tick: LND does not mint macaroons until the wallet opens, so
// without this the rotation would sit waiting for a file that cannot
// appear. `_no_wipe` keeps the destructive recovery path out of reach.
with_progress(|p| p.set("start", StepState::Running, None));
start_lnd().await.with_context(|| {
format!("starting LND after removing its macaroons — old material is in {backup}")
})?;
crate::container::lnd::unlock_existing_wallet_no_wipe()
.await
.with_context(|| format!("unlocking the wallet — old material is in {backup}"))?;
let mint_deadline =
std::time::Instant::now() + std::time::Duration::from_secs(MACAROON_WAIT_SECS);
let admin_path = format!("{LND_MAINNET_DIR}/admin.macaroon");
let mut new_digest = None;
while std::time::Instant::now() < mint_deadline {
if let Some(d) = digest_as_root(&admin_path).await {
new_digest = Some(d);
break;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
let Some(new_digest) = new_digest else {
with_progress(|p| p.set("start", StepState::Failed, None));
anyhow::bail!(
"LND did not mint a new admin.macaroon within {} minutes. The old material is intact \
in {backup} — restore it there and investigate before retrying",
MACAROON_WAIT_SECS / 60
);
};
with_progress(|p| {
p.new_admin_macaroon_sha256 = Some(new_digest.clone());
p.set(
"start",
StepState::Done,
Some("Lightning is up with freshly minted credentials".into()),
);
});
// 6. Verify the things that must NOT have changed.
//
// Its own budget, deliberately not the mint deadline. Sharing one would mean
// a rotation that legitimately spent 14 of its 15 minutes waiting for LND to
// mint gets 1 minute to prove the channels came back, and then reports
// FAILURE on a node that is perfectly healthy — the most alarming possible
// way to be wrong about someone's Lightning wallet.
with_progress(|p| p.set("verify", StepState::Running, None));
let verify_deadline =
std::time::Instant::now() + std::time::Duration::from_secs(MACAROON_WAIT_SECS);
let (pubkey_after, census_after) =
wait_for_serving(verify_deadline).await.with_context(|| {
format!("verifying the node after rotation — old material is in {backup}")
})?;
with_progress(|p| p.channels_after = Some(census_after.channels_open));
if pubkey_after != pubkey_before {
with_progress(|p| p.set("verify", StepState::Failed, None));
anyhow::bail!(
"NODE IDENTITY CHANGED — this is not the same Lightning node. Old material is in \
{backup}. Do not use this node until you understand why"
);
}
if census_after.channels_open != census_before.channels_open
|| census_after.channels_pending != census_before.channels_pending
{
with_progress(|p| p.set("verify", StepState::Failed, None));
anyhow::bail!(
"channel count changed ({} open/{} pending before, {} open/{} pending after). Old \
material is in {backup}",
census_before.channels_open,
census_before.channels_pending,
census_after.channels_open,
census_after.channels_pending
);
}
with_progress(|p| {
p.set(
"verify",
StepState::Done,
Some(format!(
"same node, same {} channel(s)",
census_after.channels_open
)),
)
});
// 7. BTCPay. Its connection string embeds the macaroon inline and cannot
// self-heal — see `rewrite_btcpay_lnd_connection_secret`. Left undone,
// the node looks healthy while every Lightning invoice BTCPay creates
// fails, which is precisely the failure this step exists to prevent.
with_progress(|p| p.set("btcpay", StepState::Running, None));
match crate::container::lnd::rewrite_btcpay_lnd_connection_secret(std::path::Path::new(
SECRETS_DIR,
))
.await
{
Ok(true) => {
// Writing the secret is only half of it. btcpay-server is
// restart-sensitive, so reconcile sees the drift and deliberately
// leaves the running container alone — which would strand it on the
// dead macaroon indefinitely. This is the flag that overrides that,
// and without it this whole step is cosmetic.
match &orchestrator {
Some(orch) => {
orch.mark_credential_rotated("btcpay-server").await;
with_progress(|p| {
p.set(
"btcpay",
StepState::Done,
Some(
"Connection string updated. BTCPay restarts itself within a \
minute or two to pick it up."
.into(),
),
)
});
}
// Only reachable in builds without an orchestrator (tests). Say
// what is left for a human rather than implying it is handled.
None => with_progress(|p| {
p.set(
"btcpay",
StepState::Skipped,
Some(
"Connection string updated, but no orchestrator is available to \
restart BTCPay — restart it yourself to pick up the new credentials."
.into(),
),
)
}),
}
}
Ok(false) => with_progress(|p| {
p.set(
"btcpay",
StepState::Skipped,
Some("No internal Lightning node is configured for BTCPay on this node.".into()),
)
}),
// Not fatal: the macaroons ARE rotated by this point, and reporting the
// whole rotation as failed would be a lie that invites a needless retry.
// Say exactly what is left undone instead.
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "btcpay connection string not updated after macaroon rotation");
with_progress(|p| {
p.set(
"btcpay",
StepState::Failed,
Some(format!(
"Your macaroons ARE rotated, but BTCPay's stored copy could not be \
updated, so its Lightning payments will fail until it is: {e:#}"
)),
)
})
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_starts_with_every_step_pending() {
let p = RotationProgress::default();
assert_eq!(p.steps.len(), STEPS.len());
assert!(p.steps.iter().all(|s| s.state == StepState::Pending));
assert!(!p.running);
assert!(p.ok.is_none());
}
#[test]
fn set_updates_only_the_named_step() {
let mut p = RotationProgress::default();
p.set("stop", StepState::Done, Some("stopped".into()));
let stop = p.steps.iter().find(|s| s.key == "stop").unwrap();
assert_eq!(stop.state, StepState::Done);
assert_eq!(stop.detail.as_deref(), Some("stopped"));
assert!(p
.steps
.iter()
.filter(|s| s.key != "stop")
.all(|s| s.state == StepState::Pending));
}
#[test]
fn set_on_an_unknown_step_is_a_no_op_not_a_panic() {
let mut p = RotationProgress::default();
p.set("not-a-step", StepState::Failed, None);
assert!(p.steps.iter().all(|s| s.state == StepState::Pending));
}
/// A detail is informational; passing `None` must not wipe one already set,
/// or a later state transition would erase the explanation the operator is
/// reading.
#[test]
fn set_without_a_detail_keeps_the_existing_one() {
let mut p = RotationProgress::default();
p.set("btcpay", StepState::Running, Some("working".into()));
p.set("btcpay", StepState::Done, None);
let step = p.steps.iter().find(|s| s.key == "btcpay").unwrap();
assert_eq!(step.state, StepState::Done);
assert_eq!(step.detail.as_deref(), Some("working"));
}
/// The serialized shape is a UI contract: the frontend renders `state`
/// as a lowercase discriminant.
#[test]
fn step_states_serialize_lowercase() {
let json = serde_json::to_string(&StepState::Skipped).unwrap();
assert_eq!(json, "\"skipped\"");
}
/// Macaroon *content* must never reach the progress feed the UI polls.
#[test]
fn progress_carries_digests_not_tokens() {
let mut p = RotationProgress::default();
p.new_admin_macaroon_sha256 = Some("a".repeat(64));
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("new_admin_macaroon_sha256"));
assert!(!json.contains("macaroon_hex"));
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
mod channels;
mod info;
mod macaroons;
mod payments;
mod seed_backup;
mod wallet;
@@ -120,7 +121,7 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
Ok(())
}
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
/// LND wedge watchdog (2026-07-22, "100% uptime"): a test node's LND sat
/// for 14 HOURS with its RPC answering but the server never finishing
/// startup — synced_to_chain=false, zero peers, every channel inactive —
/// and nothing noticed until a human tried to open a channel. The wedge
+1 -1
View File
@@ -80,7 +80,7 @@ impl RpcHandler {
// a couple of seconds and still get their answer in one round trip,
// while a slow multi-hop route flips the UI into its "settling…"
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
// freezing the modal for two minutes with no feedback (framework-pt
// freezing the modal for two minutes with no feedback (a test node
// user report, 2026-07-29).
let pay_client = reqwest::Client::builder()
.no_proxy()
+2 -2
View File
@@ -196,7 +196,7 @@ impl RpcHandler {
// input to create sweep tx: input_sum=0 BTC, output_sum=…").
// input_sum=0 with a tiny output means the wallet's coins are
// unconfirmed or below Bitcoin's dust minimum — say that
// (framework-pt sweep of 92 sats, 2026-08-06).
// (a test node sweep of 92 sats, 2026-08-06).
if msg.contains("insufficient input to create sweep tx") {
return Err(anyhow::anyhow!(
"Failed to send: your on-chain balance is too small or still \
@@ -566,7 +566,7 @@ impl RpcHandler {
// See `build_invoice_request_body` for why `private: true` is
// unconditional. This is the wallet UI's Receive flow / the
// `lnd.createinvoice` RPC — the bug diagnosed on archy-x250-mad2.
// `lnd.createinvoice` RPC — the bug diagnosed on a test node.
let invoice_body = build_invoice_request_body(amount_sats, memo);
let resp = match client
+8 -1
View File
@@ -118,11 +118,18 @@ impl RpcHandler {
.map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
let issues = marketplace::validate_manifest(&manifest);
let (trust_score, trust_tier) = marketplace::calculate_trust_score(&manifest, 0, &[]);
let signature = marketplace::verify_manifest_signature(&manifest);
let (trust_score, trust_tier) =
marketplace::calculate_trust_score(&manifest, 0, &[], &signature);
Ok(serde_json::json!({
// Security-policy compliance (advisory — these are score inputs).
"valid": issues.is_empty(),
"issues": issues,
// Cryptographic authorship: valid | missing | invalid(+reason).
// This is the one that says whether author.did is proven.
"signature": signature,
"signature_valid": signature.is_valid(),
"trust_score": trust_score,
"trust_tier": trust_tier,
}))
+4 -4
View File
@@ -87,7 +87,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Insufficient funds",
// On-chain send/sweep refusals from LND ("Failed to send: your
// on-chain balance is too small or still unconfirmed to sweep…").
// Masking sent the operator to journalctl again (framework-pt
// Masking sent the operator to journalctl again (a test node
// sweep, 2026-08-06) — same lesson as the two above.
"Failed to send",
// A frontend newer than the daemon calls methods it doesn't have.
@@ -207,7 +207,7 @@ mod sanitize_tests {
fn lightning_payment_errors_pass_through() {
// LND's payment-failure reasons are written for the payer — masking
// "invoice expired" as "Check server logs" left a user retrying a
// dead invoice (framework-pt, 2026-07-23).
// dead invoice (a test node, 2026-07-23).
for msg in [
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
"Payment failed: unable to find a path to destination",
@@ -323,10 +323,10 @@ mod client_ip_tests {
#[test]
fn loopback_connection_trusts_forwarded_header() {
// nginx on loopback forwards the real client IP — use it.
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.168.1.50"));
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.0.2.50"));
assert_eq!(
extract_client_ip(&parts),
"192.168.1.50".parse::<IpAddr>().unwrap()
"192.0.2.50".parse::<IpAddr>().unwrap()
);
}
@@ -1932,7 +1932,7 @@ autopilot.active=false\n",
.unwrap_or(true);
// Registries are configured as `host[:port]/namespace` (for
// example `146.59.87.168:3000/lfg2025`), but the Docker V2
// example `source.archipelago-foundation.org/lfg2025`), but the Docker V2
// registry API lives at `/v2/` on the ROOT of the host — NOT
// under the namespace. Strip the namespace before appending
// `/v2/` so the reachability probe hits the correct URL.
@@ -2247,7 +2247,7 @@ async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on framework-pt 2026-07-27. Kill every listener on the port
// vanished on a test node 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
@@ -131,7 +131,7 @@ rest:
Authorization: "Bearer {token}"
# 15s: the mesh-message sensor only carries the LATEST message id, so two
# messages inside one poll window coalesce and only the newest announces
# (observed on framework-pt 2026-07-22). Halving the window halves both
# (observed on a test node 2026-07-22). Halving the window halves both
# the announce lag and the coalescing odds; the endpoint is local + cheap.
scan_interval: 15
sensor:
@@ -352,7 +352,7 @@ const MESH_ANNOUNCE_AUTOMATION_ID: &str = "archy_mesh_announce";
/// state before the first-ever received message (and again whenever the mesh
/// message store restarts empty), and `unavailable` follows any endpoint
/// blip — blocking those swallowed the first DM a fresh node ever received
/// (framework-pt, 2026-07-22). HA restarts still can't re-announce an old
/// (a test node, 2026-07-22). HA restarts still can't re-announce an old
/// message: rest sensors don't restore state, so the post-restart transition
/// is always from `unknown`.
const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce
@@ -724,8 +724,8 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
///
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
/// renumbering — or the whole node moving to a different network — strands
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
/// the entry and the speaker silently drops (a test node 2026-07-23: entry
/// pinned to 192.0.2.16 while the LAN had become 192.0.2.0/24). HA
/// never re-resolves on its own. This keeper probes each satellite entry and,
/// when one stops answering, sweeps the node's local /24s for the same
/// Wyoming port and rewrites the entry to the address that answers.
@@ -46,10 +46,18 @@ fn image_tag(image: &str) -> Option<String> {
async fn installed_version(app_id: &str) -> Option<String> {
let containers = get_containers_for_app(app_id).await.ok()?;
// Prefer the backend container (exact id / `archy-<id>`) over UI companions.
let name = containers
.iter()
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
.or_else(|| containers.first())?;
//
// The fallback is deliberately narrow. It used to be `containers.first()`
// unconditionally, which for a multi-container stack reported a SIBLING's
// version as the app's own: with btcpay-server's container absent, its
// postgres dependency was first in the list, so package.versions answered
// installedVersion "15.17" against an available "2.4.2". That is not a
// cosmetic mislabel — it is the number the update decision is made from.
//
// A single-container app is unambiguous, so the fallback still covers apps
// whose container is named differently from their id. With several
// containers and no identifiable backend, "unknown" is the honest answer.
let name = select_backend_container(app_id, &containers)?;
let out = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.ImageName}}"])
.output()
@@ -74,6 +82,24 @@ async fn installed_version(app_id: &str) -> Option<String> {
Some(tag)
}
/// Pick the container that represents `app_id` itself, never a stack sibling.
///
/// See the note at the call site: an unconditional "first container" fallback
/// reported a dependency's image tag as the app's installed version.
fn select_backend_container<'a>(app_id: &str, containers: &'a [String]) -> Option<&'a str> {
if let Some(exact) = containers
.iter()
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
{
return Some(exact.as_str());
}
// Unambiguous only when there is nothing else it could be.
if containers.len() == 1 {
return Some(containers[0].as_str());
}
None
}
fn is_floating_tag(tag: &str) -> bool {
matches!(tag, "latest" | "stable" | "release" | "main")
}
@@ -295,7 +321,9 @@ impl RpcHandler {
#[cfg(test)]
mod tests {
use super::{image_tag, is_floating_tag, parse_bitcoind_version_output};
use super::{
image_tag, is_floating_tag, parse_bitcoind_version_output, select_backend_container,
};
#[test]
fn floating_tag_detects_generic_channel_names() {
@@ -332,18 +360,55 @@ mod tests {
assert_eq!(parse_bitcoind_version_output(""), None);
}
/// The BTCPay case: with btcpay-server's own container absent, its postgres
/// dependency was first in the app's container list and its tag (15.17) was
/// reported as BTCPay's installed version, against an available 2.4.2.
#[test]
fn backend_selection_never_falls_back_to_a_sibling_in_a_stack() {
let stack = vec!["archy-btcpay-db".to_string(), "archy-nbxplorer".to_string()];
assert_eq!(select_backend_container("btcpay-server", &stack), None);
let with_backend = vec!["archy-btcpay-db".to_string(), "btcpay-server".to_string()];
assert_eq!(
select_backend_container("btcpay-server", &with_backend),
Some("btcpay-server")
);
}
#[test]
fn backend_selection_accepts_a_lone_differently_named_container() {
let single = vec!["immich_server".to_string()];
assert_eq!(
select_backend_container("immich", &single),
Some("immich_server")
);
}
#[test]
fn backend_selection_prefers_the_archy_prefixed_name() {
let names = vec!["something-else".to_string(), "archy-nbxplorer".to_string()];
assert_eq!(
select_backend_container("nbxplorer", &names),
Some("archy-nbxplorer")
);
}
#[test]
fn image_tag_keeps_registry_port_colon() {
assert_eq!(
image_tag("146.59.87.168:3000/lfg2025/bitcoin:28.4").as_deref(),
image_tag("source.archipelago-foundation.org/lfg2025/bitcoin:28.4").as_deref(),
Some("28.4")
);
assert_eq!(
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(),
image_tag("source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260508")
.as_deref(),
Some("29.3.knots20260508")
);
// No tag => None (don't mistake the registry port for a tag).
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
assert_eq!(
image_tag("source.archipelago-foundation.org/lfg2025/bitcoin"),
None
);
assert_eq!(
image_tag("docker.io/library/redis:7"),
Some("7".to_string())
+10 -10
View File
@@ -774,7 +774,7 @@ fn indeedhub_stack_app_ids() -> &'static [&'static str] {
]
}
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
const REGISTRY: &str = "source.archipelago-foundation.org/lfg2025";
/// Pull an image with retry and exponential backoff (3 attempts).
async fn pull_image_with_retry(image: &str) -> Result<()> {
@@ -871,9 +871,9 @@ impl RpcHandler {
}
let images = [
"146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
"146.59.87.168:3000/lfg2025/valkey:7-alpine",
"146.59.87.168:3000/lfg2025/immich-server:release",
"source.archipelago-foundation.org/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
"source.archipelago-foundation.org/lfg2025/valkey:7-alpine",
"source.archipelago-foundation.org/lfg2025/immich-server:release",
];
self.set_install_phase("immich", InstallPhase::PullingImage)
.await;
@@ -947,7 +947,7 @@ impl RpcHandler {
"POSTGRES_USER=postgres",
"-e",
"POSTGRES_DB=immich",
"146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
"source.archipelago-foundation.org/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
]);
run_required_stack_command("immich", "create postgres", &mut postgres_cmd).await?;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
@@ -973,7 +973,7 @@ impl RpcHandler {
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
"146.59.87.168:3000/lfg2025/valkey:7-alpine",
"source.archipelago-foundation.org/lfg2025/valkey:7-alpine",
]);
run_required_stack_command("immich", "create redis", &mut redis_cmd).await?;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -1016,7 +1016,7 @@ impl RpcHandler {
"REDIS_HOSTNAME=immich_redis".to_string(),
"-e".to_string(),
"UPLOAD_LOCATION=/usr/src/app/upload".to_string(),
"146.59.87.168:3000/lfg2025/immich-server:release".to_string(),
"source.archipelago-foundation.org/lfg2025/immich-server:release".to_string(),
],
)
.await?;
@@ -1076,7 +1076,7 @@ impl RpcHandler {
let images = [
&format!("{}/postgres:15.17", REGISTRY),
&format!("{}/nbxplorer:2.6.0", REGISTRY),
"docker.io/btcpayserver/btcpayserver:2.3.9",
"docker.io/btcpayserver/btcpayserver:2.4.2",
];
self.set_install_phase("btcpay-server", InstallPhase::PullingImage)
.await;
@@ -1233,7 +1233,7 @@ impl RpcHandler {
"BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
db_pass
),
"docker.io/btcpayserver/btcpayserver:2.3.9",
"docker.io/btcpayserver/btcpayserver:2.4.2",
])
.output()
.await
@@ -1517,7 +1517,7 @@ impl RpcHandler {
.into_iter()
.find(|r| r.enabled)
.map(|r| r.url)
.unwrap_or_else(|| "146.59.87.168:3000/lfg2025".to_string());
.unwrap_or_else(|| "source.archipelago-foundation.org/lfg2025".to_string());
let user_tmp = format!(
"{}/.local/share/containers/tmp",
+1 -1
View File
@@ -155,7 +155,7 @@ pub(super) fn detect_hidden_service_base() -> String {
///
/// Derived at runtime, never hardcoded: `archy-net` is created without an
/// explicit subnet (see docker-compose.yml), so podman allocates one — it is
/// 10.89.0.0/24 on archi-dev-box but there is no guarantee of that on another
/// 10.89.0.0/24 on a test node but there is no guarantee of that on another
/// node, and a hardcoded guess would fail silently by binding SOCKS to an
/// address no container can reach.
///
+4 -4
View File
@@ -86,7 +86,7 @@ impl PortMap {
/// thing that can publish a port: the FIPS mesh relay bridges the fips0
/// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer
/// 32838 — declared `local` and pinned to loopback — to the mesh
/// unauthenticated (archi-dev-box 2026-08-04). Anything that republishes
/// unauthenticated (test node 2026-08-04). Anything that republishes
/// a loopback port must consult this set first.
pub fn is_declared_local(&self, port: u16) -> bool {
self.local.contains(&port)
@@ -135,7 +135,7 @@ fn manifest_icon(manifest: &AppManifest) -> Option<String> {
/// from disk alone made the gate act on policy the node was no longer
/// running: the catalog declared nbxplorer `auth: local` and pinned it to
/// loopback, the stale disk manifest declared nothing, and the gate
/// externally bound a deliberately host-local port (archi-dev-box
/// externally bound a deliberately host-local port (a test node
/// 2026-08-04).
///
/// After the catalog, the first directory that yields a manifest for an app
@@ -268,7 +268,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
// and a node's installed manifests always lag the
// repo. Binding those externally published Bitcoin
// RPC across the LAN within seconds of deploy
// (archi-dev-box 2026-08-03). Taking over a port is
// (test node 2026-08-03). Taking over a port is
// opt-in only: `auth: gated`, shipped in the same
// manifest edit as the loopback pin.
if port
@@ -446,7 +446,7 @@ app:
/// This is not hypothetical. `session` is the default, so it is what
/// every un-migrated manifest carries, and a node's installed manifests
/// always lag the repo. An earlier revision gated these regardless of
/// `bind`, and within seconds of deploying to archi-dev-box the daemon
/// `bind`, and within seconds of deploying to a test node the daemon
/// had published Bitcoin's loopback-only RPC 8332 on the LAN, Tailscale
/// and IPv6 addresses. Taking over a port must be opt-in.
#[test]
+1 -1
View File
@@ -155,7 +155,7 @@ pub async fn run(
// RELEASED when its port leaves the gated set — a catalog refresh
// declaring a port `local`/`none` must make the gate let go without a
// daemon restart, or the stale bind keeps republishing a port the
// catalog just withdrew (nbxplorer 32838, archi-dev-box 2026-08-04).
// catalog just withdrew (nbxplorer 32838, a test node 2026-08-04).
let mut held: HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>> = HashMap::new();
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+3 -3
View File
@@ -600,7 +600,7 @@ fn icon_markup(app: &GatedPort) -> String {
// A manifest that names no icon still gets one: the dashboard already
// ships icons named after the app, so fall back to those before
// giving up. Without this EVERY gated app showed a lettermark,
// because no manifest declares metadata.icon (archi-dev-box,
// because no manifest declares metadata.icon (test node,
// 2026-08-05).
.or_else(|| {
icon_candidates(&app.app_id)
@@ -845,7 +845,7 @@ button:active {{ transform:translateY(1px); }}
// NOT X-Frame-Options: DENY. My Apps opens an app in an embedded
// frame, so a blanket DENY made every gated app render as "app is
// not responding" the moment the gate challenged it (reported on
// 100.82.34.38, 2026-08-05). frame-ancestors is the modern control
// a fleet node, 2026-08-05). frame-ancestors is the modern control
// and can be precise: only pages from this same node may frame the
// login, on any port or scheme, which is exactly the dashboard.
// Anything else — another site embedding it to harvest the node
@@ -1003,7 +1003,7 @@ mod tests {
/// The challenge must be framable by this node's own dashboard — My Apps
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
/// turned every gated app into "app is not responding" (100.82.34.38,
/// turned every gated app into "app is not responding" (observed on a fleet node,
/// 2026-08-05). It must still be uncacheable, and still refuse to be
/// framed by a foreign origin, which `frame-ancestors` expresses and
/// `X-Frame-Options` cannot.
+2 -2
View File
@@ -411,7 +411,7 @@ mod tests {
bind_host = "127.0.0.1"
bind_port = 9999
log_level = "debug"
host_ip = "192.168.1.100"
host_ip = "192.0.2.100"
dev_mode = true
container_runtime = "Podman"
port_offset = 20000
@@ -425,7 +425,7 @@ mod tests {
assert_eq!(config.bind_host, "127.0.0.1");
assert_eq!(config.bind_port, 9999);
assert_eq!(config.log_level, "debug");
assert_eq!(config.host_ip, "192.168.1.100");
assert_eq!(config.host_ip, "192.0.2.100");
assert!(config.dev_mode);
assert_eq!(config.port_offset, 20000);
assert!(!config.nostr_discovery_enabled);
@@ -225,7 +225,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
/// same origin. They diverged once — the orchestrator published containers
/// from the catalog while the gate classified from stale disk manifests, and
/// the gate externally bound a port the catalog had declared `auth: local`
/// (nbxplorer 32838, archi-dev-box 2026-08-04).
/// (nbxplorer 32838, a test node 2026-08-04).
pub fn catalog_manifest_overlay(
app_id: &str,
value: serde_json::Value,
@@ -546,7 +546,7 @@ mod tests {
"apps": {
"indeedhub": {
"version": "1.0.1",
"image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1",
"image": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.1",
"digest": "blake3:deadbeef",
"size": 12345,
"another_future_field": true
@@ -560,7 +560,7 @@ mod tests {
assert_eq!(e.version, "1.0.1");
assert_eq!(
e.image.as_deref(),
Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1")
Some("source.archipelago-foundation.org/lfg2025/indeedhub:1.0.1")
);
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
}
@@ -628,7 +628,7 @@ mod tests {
#[test]
fn catalog_url_derived_from_mirror() {
let mirrors = vec![crate::update::UpdateMirror {
url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
url: "https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(),
label: "Server 1".to_string(),
}];
@@ -636,7 +636,7 @@ mod tests {
assert_eq!(
urls,
vec![
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
.to_string()
]
);
@@ -31,7 +31,7 @@ server {
# on the fips0 mesh allowlist (fips/app_ports.rs), so any mesh peer, LAN
# host or Tailscale peer could POST authenticated Bitcoin Core RPC —
# including wallet methods, with a wallet loaded. Verified live on
# archi-dev-box 2026-08-02.
# a test node 2026-08-02.
#
# `Access-Control-Allow-Origin *` is also removed: paired with a proxy that
# injects credentials it let any web page a user visited drive this RPC.
@@ -153,7 +153,7 @@ impl BootReconciler {
// A failed repair can involve registry pulls and full
// image builds; retrying every 30s hammered unreachable
// registries ~174×/image/day on an offline node
// (archy-x250-dev log sweep, 2026-07-22). Back off
// (a test node log sweep, 2026-07-22). Back off
// exponentially while rounds keep failing — 30s doubling
// to a 1h cap — and reset the moment a round is clean.
failure_rounds = if failures.is_empty() {
+4 -4
View File
@@ -33,14 +33,14 @@ use tracing::{info, warn};
use crate::container::quadlet::{self, BindMount, NetworkMode, QuadletUnit};
use archipelago_container::image_uses_insecure_registry;
const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
const COMPANION_REGISTRY: &str = "source.archipelago-foundation.org/lfg2025";
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
/// After a failed repair (image build/pull included), leave the companion
/// alone for this long. Without it, a node under IO pressure retried a 900s
/// image build every 30s reconcile tick — each build pegging the disk that
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
/// made the probes fail in the first place (live-diagnosed on a test node
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
@@ -69,7 +69,7 @@ pub struct CompanionSpec {
/// Container + unit name (e.g. "archy-bitcoin-ui").
pub name: &'static str,
/// Image base name in the lfg2025 registry namespace
/// (e.g. "bitcoin-ui" → "146.59.87.168:3000/lfg2025/bitcoin-ui:latest").
/// (e.g. "bitcoin-ui" → "source.archipelago-foundation.org/lfg2025/bitcoin-ui:latest").
pub image_base: &'static str,
/// Filesystem locations to look for a local Dockerfile (build wins
/// over registry pull). Searched in order; first hit wins.
@@ -239,7 +239,7 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
// `ensure_image_present` rebuilds in place under the same tag, so the unit
// body is byte-identical, `write_if_changed` reports no change, and
// `enable_now` is a no-op on a running service — the container keeps the
// old layers indefinitely. That is exactly how archi-dev-box kept serving
// old layers indefinitely. That is exactly how a test node kept serving
// the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123
// rebuilt every one of those images to bind loopback: the images were
// correct on disk and the running containers were three days old
+20 -4
View File
@@ -4,17 +4,33 @@
//! manifest-supplied ref can't reach `pull_image` unchecked (§A of the
//! 1.8.0 hardening plan).
/// The registry's previous address, before it moved behind a domain.
///
/// TRANSITIONAL — remove once the app catalog has been regenerated and
/// re-signed against `source.archipelago-foundation.org`. The catalog is a
/// signed artifact, so its image refs cannot be rewritten in place without
/// invalidating the signature; until the signing ceremony runs, deployed
/// nodes still resolve every app through a catalog that names this host.
/// Dropping it from the trusted list before then makes each catalog-driven
/// install fail with "not from a trusted registry".
pub const LEGACY_REGISTRY_HOST: &str = "146.59.87.168:3000";
/// 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] =
&["docker.io", "ghcr.io", "localhost", "146.59.87.168:3000"];
pub const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io",
"ghcr.io",
"localhost",
"source.archipelago-foundation.org",
LEGACY_REGISTRY_HOST,
];
/// Validate a container image reference.
///
/// Accepts:
/// * refs whose explicit registry host is on [`TRUSTED_REGISTRIES`]
/// (`docker.io/grafana/grafana`, `146.59.87.168:3000/archy/x:1`), and
/// (`docker.io/grafana/grafana`, `source.archipelago-foundation.org/archy/x:1`), and
/// * registry-less Docker Hub shorthand (`nginx`, `grafana/grafana`) —
/// the first segment has no `.`/`:` so it cannot name an attacker host;
/// resolution follows the host's registries.conf search order.
@@ -55,7 +71,7 @@ mod tests {
"docker.io/library/nginx:1.25",
"ghcr.io/owner/app:latest",
"localhost/archy-dev:1",
"146.59.87.168:3000/archy/bitcoin-knots:28.1",
"source.archipelago-foundation.org/archy/bitcoin-knots:28.1",
] {
assert!(is_valid_docker_image(img), "{img} should be accepted");
}
+125 -13
View File
@@ -230,11 +230,56 @@ pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<
return None;
}
// Never advertise a LOWER version as an update.
//
// Everything upstream of here is a version claim that can go stale: the
// signed catalog, a legacy catalog entry with no manifest, the
// image-versions.sh baseline pin. When one lags behind what a node is
// actually running, a bare `pinned != running` check turns that staleness
// into an "Update" button that rolls the node BACKWARDS — and a rollback
// to a version withdrawn for a vulnerability is precisely the case where
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
// 2.3.9 pin, and the UI offering "update" to the exploited release.
//
// Only suppress when both tags parse as comparable version numbers, so
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
// keep the previous behaviour rather than silently losing updates.
if let (Some(p), Some(r)) = (
parse_version_parts(&pinned_version),
parse_version_parts(&running_version),
) {
if p < r {
return None;
}
}
Some(pinned_version)
}
/// Numeric components of a version tag, for ordering comparisons only.
///
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
/// comparing on the dotted numbers alone. Returns None when the tag is not a
/// recognisable dotted-numeric version, which the caller treats as "cannot
/// order these" rather than as equality.
fn parse_version_parts(tag: &str) -> Option<Vec<u64>> {
let core = tag.strip_prefix('v').unwrap_or(tag);
// Drop a pre-release/build suffix: 0.18.4-beta -> 0.18.4
let core = core.split(['-', '+', '_']).next().unwrap_or(core);
if core.is_empty() {
return None;
}
let parts: Vec<&str> = core.split('.').collect();
let mut out = Vec::with_capacity(parts.len());
for part in parts {
// Any non-numeric component makes the whole tag unorderable.
out.push(part.parse::<u64>().ok()?);
}
Some(out)
}
/// Extract version tag from a full image reference.
/// e.g. "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// e.g. "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty.
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
@@ -328,11 +373,13 @@ mod tests {
#[test]
fn test_extract_version() {
assert_eq!(
extract_version_from_image("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
extract_version_from_image(
"source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta"
),
"v0.18.4-beta"
);
assert_eq!(
extract_version_from_image("146.59.87.168:3000/lfg2025/grafana:10.2.0"),
extract_version_from_image("source.archipelago-foundation.org/lfg2025/grafana:10.2.0"),
"10.2.0"
);
assert_eq!(
@@ -340,7 +387,9 @@ mod tests {
"latest"
);
assert_eq!(
extract_version_from_image("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"),
extract_version_from_image(
"source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest"
),
"latest"
);
}
@@ -348,11 +397,15 @@ mod tests {
#[test]
fn strips_registry_and_tag_for_image_identity() {
assert_eq!(
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
image_without_registry_or_tag(
"source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta"
),
"lfg2025/lnd"
);
assert_eq!(
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
image_without_registry_or_tag(
"source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta"
),
"lfg2025/lnd"
);
}
@@ -368,8 +421,8 @@ mod tests {
fn available_update_ignores_registry_only_changes() {
assert_eq!(
available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29",
"146.59.87.168:3000/lfg2025/nextcloud:29",
"source.archipelago-foundation.org/lfg2025/nextcloud:29",
"source.archipelago-foundation.org/lfg2025/nextcloud:29",
),
None
);
@@ -379,8 +432,8 @@ mod tests {
fn available_update_returns_pinned_version_for_same_repo_newer_tag() {
assert_eq!(
available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29",
"146.59.87.168:3000/lfg2025/nextcloud:28",
"source.archipelago-foundation.org/lfg2025/nextcloud:29",
"source.archipelago-foundation.org/lfg2025/nextcloud:28",
),
Some("29".to_string())
);
@@ -389,7 +442,7 @@ mod tests {
#[test]
fn test_parse_image_versions() {
let content = r#"
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
ARCHY_REGISTRY="source.archipelago-foundation.org/lfg2025"
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
# comment
@@ -398,11 +451,11 @@ NOT_AN_IMAGE="something"
let parsed = parse_image_versions(content);
assert_eq!(
parsed.get("LND_IMAGE"),
Some(&"146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta".to_string())
Some(&"source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta".to_string())
);
assert_eq!(
parsed.get("GRAFANA_IMAGE"),
Some(&"146.59.87.168:3000/lfg2025/grafana:10.2.0".to_string())
Some(&"source.archipelago-foundation.org/lfg2025/grafana:10.2.0".to_string())
);
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
@@ -417,4 +470,63 @@ NOT_AN_IMAGE="something"
);
assert_eq!(image_var_for_app("unknown-app"), None);
}
/// The BTCPay case that prompted the guard: 2.4.2 shipped for an actively
/// exploited 2FA bypass, a stale 2.3.9 pin left in a legacy catalog entry,
/// and the UI offering the withdrawn release as an "update".
#[test]
fn never_advertises_a_downgrade_as_an_update() {
let stale = "docker.io/btcpayserver/btcpayserver:2.3.9";
let running = "docker.io/btcpayserver/btcpayserver:2.4.2";
assert_eq!(available_update_for_images(stale, running), None);
}
#[test]
fn still_advertises_a_genuine_upgrade() {
let pinned = "docker.io/btcpayserver/btcpayserver:2.4.2";
let running = "docker.io/btcpayserver/btcpayserver:2.3.9";
assert_eq!(
available_update_for_images(pinned, running),
Some("2.4.2".to_string())
);
}
#[test]
fn equal_versions_offer_nothing() {
let same = "docker.io/btcpayserver/btcpayserver:2.4.2";
assert_eq!(available_update_for_images(same, same), None);
}
#[test]
fn prerelease_suffixes_compare_on_their_numbers() {
let older = "example.test/lfg2025/lnd:v0.18.3-beta";
let newer = "example.test/lfg2025/lnd:v0.18.4-beta";
assert_eq!(available_update_for_images(older, newer), None);
assert_eq!(
available_update_for_images(newer, older),
Some("v0.18.4-beta".to_string())
);
}
/// Opaque tags stay on the old behaviour: we cannot order them, so a
/// difference is still reported rather than silently swallowed.
#[test]
fn unorderable_tags_keep_previous_behaviour() {
let a = "example.test/lfg2025/minio:RELEASE.2024-11-07T00-52-20Z";
let b = "example.test/lfg2025/minio:RELEASE.2024-10-01T00-00-00Z";
assert_eq!(
available_update_for_images(a, b),
Some("RELEASE.2024-11-07T00-52-20Z".to_string())
);
}
#[test]
fn parse_version_parts_rejects_non_numeric() {
assert_eq!(parse_version_parts("2.4.2"), Some(vec![2, 4, 2]));
assert_eq!(parse_version_parts("v0.18.4-beta"), Some(vec![0, 18, 4]));
assert_eq!(parse_version_parts("28.4"), Some(vec![28, 4]));
assert_eq!(parse_version_parts("RELEASE.2024-11-07T00-52-20Z"), None);
assert_eq!(parse_version_parts("14-vectorchord0.4.3"), Some(vec![14]));
assert_eq!(parse_version_parts("latest"), None);
}
}
+115 -11
View File
@@ -22,6 +22,11 @@ const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-pa
/// never use it, and the login-path migration rotates away from it.
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
/// How many one-second passes `unlock_existing_wallet_via_rest` will make while
/// LND's unlocker is still not listening (~10 minutes). See the comment at the
/// retry loop for why this is measured in minutes rather than seconds.
const UNLOCK_NOT_READY_ATTEMPTS: u32 = 600;
#[derive(Debug, Clone)]
pub struct EnsurePaths {
pub data_dir: PathBuf,
@@ -345,7 +350,18 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
// with a clear message instead of hanging the boot path for 60s+ (the wallet
// was created with a password this node doesn't have → migration/recovery).
for _ in 0..60 {
//
// The not-ready budget is deliberately generous. LND opens channel.db,
// graph.db and wallet.db before it starts serving the unlocker at all, and
// on a busy node that is genuinely slow — observed at 2m38s on a box running
// 30 containers, where a 60s budget could never succeed. Timing out here is
// not a harmless retry: reconcile records the post-start hook as failed,
// which restarts LND, which starts the slow database open over again. The
// result is a restart loop that leaves the wallet permanently locked and
// every LND-dependent app (BTCPay's internal node included) broken, on
// exactly the nodes least able to afford it. Waiting longer costs nothing —
// a wrong password still exits on the first pass via `all_rejected`.
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
let mut all_rejected = true;
for pw in &candidates {
match try_unlock_once(&client, pw).await {
@@ -364,7 +380,28 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
anyhow::bail!(
"LND wallet unlock timed out after ~{}s waiting for the unlocker to become ready",
UNLOCK_NOT_READY_ATTEMPTS
)
}
/// Unlock an existing wallet WITHOUT the destructive fallback.
///
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
/// password can open — correct for a boot path that must self-heal, and exactly
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
/// still wants. Rotation calls this instead, so there is no code path from
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
/// as an error the caller reports, never as a wipe.
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
match unlock_existing_wallet().await? {
true => Ok(()),
false => anyhow::bail!(
"LND rejected every candidate wallet password — refusing to touch the wallet. \
The wallet is intact and still locked; its password is not one this node holds."
),
}
}
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
@@ -718,29 +755,73 @@ const BTCPAY_LND_CONNECTION_SECRET: &str = "btcpay-lnd-connection";
/// 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
/// rotation). Macaroon rotation without cert rotation is not auto-detected here
/// (reading the macaroon needs sudo; probing it every tick is not worth the
/// churn) — delete the secret file once to force regeneration.
/// churn) — the rotation path calls `rewrite_btcpay_lnd_connection_secret`
/// instead, and deleting the secret file also forces regeneration.
pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path) -> Result<()> {
build_btcpay_lnd_connection_secret(secrets_dir, false)
.await
.map(|_| ())
}
/// Rewrite the BTCPay→LND connection secret unconditionally, ignoring the
/// cert-thumbprint fast path.
///
/// Rotating LND's macaroons invalidates the one embedded in this secret, and it
/// is embedded *inline* rather than referenced by path — LND's datadir is owned
/// by its container subuid, so btcpay cannot bind-mount the file and the string
/// cannot self-heal. Nothing else notices: the TLS cert is untouched by macaroon
/// rotation, so `ensure_…` takes its fast path forever and BTCPay keeps
/// presenting a credential LND no longer honours. A node in that state looks
/// entirely healthy — btcpay is up, LND is up — while every Lightning invoice it
/// tries to create fails.
///
/// Writing the new value makes the change *visible*: `secret_env_hash` is
/// derived from the resolved secret contents, so a changed file reads as label
/// drift on the running container. It is not sufficient on its own — btcpay is
/// restart-sensitive, and boot reconcile deliberately leaves running
/// restart-sensitive apps untouched on drift. The caller must also call
/// `ContainerOrchestrator::mark_credential_rotated("btcpay-server")`, which is
/// the carve-out for exactly this case: a container that is up and healthy while
/// holding a credential that no longer works. The orchestrator's own recreate
/// path then rebuilds it around an unchanged data directory. No teardown here,
/// deliberately — a hand-rolled remove-and-run is the anti-pattern CLAUDE.md
/// names.
///
/// Returns `false` when LND isn't provisioned enough to derive a value.
pub async fn rewrite_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path) -> Result<bool> {
build_btcpay_lnd_connection_secret(secrets_dir, true).await
}
/// Shared body. `force` skips the "already pins the current cert" fast path.
/// Returns whether a value was written.
async fn build_btcpay_lnd_connection_secret(
secrets_dir: &std::path::Path,
force: bool,
) -> Result<bool> {
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
Err(_) => return Ok(false), // 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(());
if !force {
if let Ok(existing) = fs::read_to_string(&target).await {
if !existing.trim().is_empty()
&& existing.contains(&format!("certthumbprint={thumbprint}"))
{
return Ok(false);
}
}
}
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
return Ok(false); // wallet not created yet; next tick retries
}
let macaroon = read_file_as_root(&macaroon_path).await?;
let value = format!(
@@ -749,7 +830,30 @@ pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path)
thumbprint
);
crate::container::secrets::write_secret_file(&target, &value)
.context("writing btcpay-lnd-connection secret")
.context("writing btcpay-lnd-connection secret")?;
Ok(true)
}
/// Does the on-disk BTCPay connection secret still carry the macaroon LND is
/// currently issuing? `None` when there is nothing to compare — no secret file
/// (BTCPay has no internal node configured) or no macaroon (LND unprovisioned).
///
/// Compares only hex text that is already on this host; the value is never
/// logged, returned over RPC, or placed in an error.
pub(crate) async fn btcpay_lnd_connection_is_current(
secrets_dir: &std::path::Path,
) -> Option<bool> {
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
let existing = fs::read_to_string(&target).await.ok()?;
let embedded = existing
.split("macaroon=")
.nth(1)?
.split(';')
.next()?
.to_string();
let macaroon_path = format!("{DEFAULT_DATA_DIR}/data/chain/bitcoin/mainnet/admin.macaroon");
let current = read_file_as_root(&macaroon_path).await.ok()?;
Some(embedded.eq_ignore_ascii_case(&hex::encode(current)))
}
/// SHA256 over the DER certificate body (matches
@@ -315,7 +315,7 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
// the subuid range first: `sudo chown 999` writes literal host uid 999,
// which maps to nobody inside the userns — the app then can't open its
// own files while the chown reported success (botfights SQLITE_CANTOPEN
// crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at
// crash-loop, a test node 2026-08-06). Container uid N (N>=1) lives at
// subuid_base + N - 1; the fleet provisions base 100000. uid 0 and
// already-mapped ids (>=100000) pass through untouched.
let status = host_sudo(&["chown", "-R", &host_uid_gid, path])
@@ -2178,7 +2178,7 @@ impl ProdContainerOrchestrator {
// The marker says "removed", but the container can still be
// RUNNING: a Quadlet unit is owned by systemd, which starts it
// on boot entirely independently of this reconciler. On
// archi-dev-box (2026-08-02) `bitcoin-ui` sat in this exact
// a test node (2026-08-02) `bitcoin-ui` sat in this exact
// state — marker set, `archy-bitcoin-ui.service` active, port
// 8334 published — and so it never received the /bitcoin-rpc/
// auth_request gate that a05956c4 shipped INSIDE the binary.
@@ -3558,7 +3558,7 @@ impl ProdContainerOrchestrator {
// Mark the app so the drift check below recreates it even though
// it is restart-sensitive. Without this the unit is rewritten but
// never restarted, and the gateway keeps serving the compromised
// credential indefinitely (observed on archi-dev-box 2026-08-01).
// credential indefinitely (observed on a test node 2026-08-01).
self.credential_rotated
.lock()
.await
@@ -4584,6 +4584,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
ContainerState::Unknown(s) => format!("unknown:{s}"),
})
}
async fn mark_credential_rotated(&self, app_id: &str) {
self.credential_rotated
.lock()
.await
.insert(app_id.to_string());
tracing::info!(
app_id = %app_id,
"a credential this app consumes was rotated — its running container will be recreated \
on the next drift check even though the app is restart-sensitive"
);
}
}
/// Compare a manifest's command against a live container's, over the
@@ -5359,7 +5371,7 @@ app:
name: File Browser
version: 1.0.0
container:
image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0
custom_args:
- --config
- /data/.filebrowser.json
@@ -5383,7 +5395,7 @@ app:
name: LND
version: 1.0.0
container:
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
image: source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta
secret_env:
- key: BITCOIND_RPCPASS
secret_file: bitcoin-rpc-password
@@ -5565,7 +5577,7 @@ app:
/// holding the old value, so the rotation must flag the app for recreate.
/// Without the flag the drift check skips it as restart-sensitive and the
/// gateway keeps serving the published default forever — observed on
/// archi-dev-box 2026-08-01 before this was wired up.
/// a test node 2026-08-01 before this was wired up.
#[tokio::test]
async fn rotating_a_compromised_credential_flags_the_app_for_recreate() {
let rt = Arc::new(MockRuntime::default());
@@ -6372,7 +6384,7 @@ app:
#[tokio::test]
async fn reconcile_rewrites_security_config_for_a_user_uninstalled_but_running_app() {
// Regression, archi-dev-box 2026-08-02: `bitcoin-ui` carried a durable
// Regression, a test node 2026-08-02: `bitcoin-ui` carried a durable
// user-uninstalled marker WHILE systemd/Quadlet kept archy-bitcoin-ui
// running and publishing :8334. Reconcile returned on the marker before
// reaching the pre-start hook, so the /bitcoin-rpc/ auth_request gate
+4 -2
View File
@@ -982,7 +982,7 @@ mod tests {
QuadletUnit {
name: "archy-bitcoin-ui".into(),
description: "Bitcoin RPC UI proxy".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha".into(),
image: "source.archipelago-foundation.org/lfg2025/bitcoin-ui:1.7.84-alpha".into(),
network: NetworkMode::Host,
user: Some("0:0".into()),
memory_mb: Some(128),
@@ -1010,7 +1010,9 @@ mod tests {
let s = sample_unit().render();
assert!(s.contains("[Container]"));
assert!(s.contains("ContainerName=archy-bitcoin-ui"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha"));
assert!(
s.contains("Image=source.archipelago-foundation.org/lfg2025/bitcoin-ui:1.7.84-alpha")
);
assert!(s.contains("Pull=never"));
assert!(s.contains("Network=host"));
assert!(s.contains("DropCapability=ALL"));
+7 -7
View File
@@ -10,7 +10,7 @@ use std::path::Path;
use tokio::fs;
const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
const OVH_REGISTRY_URL: &str = "source.archipelago-foundation.org/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
@@ -21,7 +21,7 @@ const RETIRED_TX1138_HOST: &str = "git.tx1138.com";
/// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registry {
/// Registry URL (e.g., "146.59.87.168:3000/lfg2025").
/// Registry URL (e.g., "source.archipelago-foundation.org/lfg2025").
pub url: String,
/// Human-readable name.
pub name: String,
@@ -68,8 +68,8 @@ impl RegistryConfig {
}
/// Rewrite an image reference to use a specific registry.
/// 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".
/// E.g., "docker.io/lfg2025/bitcoin-knots:latest" with registry "source.archipelago-foundation.org/lfg2025"
/// becomes "source.archipelago-foundation.org/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"
@@ -79,7 +79,7 @@ impl RegistryConfig {
}
/// Extract the image name from a full image reference.
/// "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
/// "source.archipelago-foundation.org/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)
@@ -212,7 +212,7 @@ mod tests {
#[test]
fn test_extract_image_name() {
assert_eq!(
extract_image_name("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"),
extract_image_name("source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest"),
"bitcoin-knots:latest"
);
assert_eq!(
@@ -230,7 +230,7 @@ mod tests {
let primary = &config.registries[0];
assert_eq!(
config.rewrite_image("docker.io/lfg2025/bitcoin-knots:latest", primary),
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
"source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest"
);
}
+17
View File
@@ -73,4 +73,21 @@ pub trait ContainerOrchestrator: Send + Sync {
/// Coarse health summary: "healthy", "unhealthy", "starting", "paused", "unknown".
async fn health(&self, app_id: &str) -> Result<String>;
/// Declare that a credential this app consumes has just been rotated, so
/// the running container is now holding an invalid one.
///
/// Restart-sensitivity normally protects apps like `btcpay-server` from
/// being recreated on drift — correct when the running container is
/// working, and exactly wrong when it is working only in appearance. After
/// an LND macaroon rotation, BTCPay is up and healthy while every Lightning
/// operation it attempts fails against a credential LND no longer honours;
/// leaving it untouched perpetuates the breakage rather than protecting
/// anything. This is the same carve-out FED-07 uses for the Fedimint
/// gateway, reached from the RPC layer instead of from inside a reconcile.
///
/// Consumed by the next drift check, which recreates the container around
/// its unchanged data directory, ports and volumes. Default no-op: an
/// orchestrator without restart-sensitivity has nothing to override.
async fn mark_credential_rotated(&self, _app_id: &str) {}
}
+4 -4
View File
@@ -123,7 +123,7 @@ pub fn default_public_anchors() -> Vec<SeedAnchor> {
pub struct SeedAnchor {
/// Bech32 `npub1...` of the anchor's FIPS identity.
pub npub: String,
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
/// Directly-dialable transport address, e.g. `192.0.2.12:8668`.
pub address: String,
/// Transport to use — almost always `"udp"`.
#[serde(default = "default_transport")]
@@ -477,19 +477,19 @@ mod tests {
fn lan_fips_anchors_builds_direct_entry() {
let peer = crate::transport::PeerRecord {
did: "did:key:zpeer".to_string(),
lan_address: Some("192.168.63.198:5678".to_string()),
lan_address: Some("192.0.2.198:5678".to_string()),
fips_npub: Some("npub1peer".to_string()),
..Default::default()
};
let out = lan_fips_anchors(&[peer]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
assert_eq!(out[0].address, format!("192.0.2.198:{FIPS_UDP_PORT}"));
assert_eq!(out[0].transport, "udp");
// Peers missing either the LAN address or the npub produce nothing.
let no_npub = crate::transport::PeerRecord {
did: "did:key:zother".to_string(),
lan_address: Some("192.168.63.199:5678".to_string()),
lan_address: Some("192.0.2.199:5678".to_string()),
..Default::default()
};
assert!(lan_fips_anchors(&[no_npub]).is_empty());
+1 -1
View File
@@ -237,7 +237,7 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
// out-of-band) default-denies inbound on fips0 — without an explicit
// allowance the node's web UI is unreachable over the mesh (phones got
// RST on :80 with a healthy session; root-caused 2026-07-26 on
// framework-pt). Ship the allowance as a fips.d drop-in on every
// a test node). Ship the allowance as a fips.d drop-in on every
// install/upgrade so no node ever regresses to a UI-less mesh.
sudo_install_dir("/etc/fips/fips.d").await?;
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
+3 -3
View File
@@ -153,11 +153,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let connected = vec![ConnectedPeer {
npub: "npub1aaa".into(),
address: "100.114.134.21:2121".into(),
address: "100.64.0.21:2121".into(),
transport: "udp".into(),
}];
let map = record_connected(dir.path(), &connected).await;
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
assert_eq!(map["npub1aaa"].address, "100.64.0.21:2121");
let reloaded = load(dir.path()).await;
assert_eq!(reloaded, map);
}
@@ -192,7 +192,7 @@ mod tests {
let connected = vec!["npub1conn".to_string()];
let lan = vec![SeedAnchor {
npub: "npub1lan".into(),
address: "192.168.63.198:2121".into(),
address: "192.0.2.198:2121".into(),
transport: "udp".into(),
label: "LAN".into(),
}];
+4 -4
View File
@@ -110,8 +110,8 @@ mod tests {
#[test]
fn route_src_wins() {
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600";
assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116"));
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.0.2.12 metric 600";
assert_eq!(parse_route_src(route).as_deref(), Some("192.0.2.12"));
}
#[test]
@@ -123,8 +123,8 @@ mod tests {
#[test]
fn addr_inet_strips_prefix() {
let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65"));
let out = "3: wlp3s0 inet 192.0.2.17/24 brd 192.0.2.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.0.2.17"));
}
#[test]
+1 -1
View File
@@ -210,7 +210,7 @@ async fn main() -> Result<()> {
// that window (a superseding start/restart, a start-timeout) killed a
// half-started instance, which then exited 0 and (under the old
// Restart=on-failure) never came back: "server starting up" forever,
// reproduced on framework-pt installing apps on 2026-07-26. The daemon's
// reproduced on a test node installing apps on 2026-07-26. The daemon's
// real work (recovery, reconcile, listener) continues after READY; being
// "active" early is honest — the process is up and doing its job.
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]);
+503 -16
View File
@@ -158,6 +158,181 @@ pub struct DiscoveredApp {
pub relay_count: u32,
pub first_seen: String,
pub nostr_pubkey: String,
/// Outcome of checking the manifest's DID signature. `#[serde(default)]`
/// so a cache written before this field existed still loads (as `missing`,
/// which is the honest answer for an entry we never verified).
#[serde(default)]
pub signature: SignatureStatus,
}
// ─── DID signature layer ────────────────────────────────────────────────
//
// A marketplace manifest travels inside a Nostr event, so it already carries a
// NIP-01 Schnorr signature proving *the publishing relay key* sent it. That
// says nothing about the `author.did` the manifest claims. This layer closes
// that gap: the author signs a digest of their own manifest with the Ed25519
// key their did:key encodes, and every consumer re-derives the digest and
// checks it.
//
// Until this existed, `signatures.manifest_hash` / `signatures.did_signature`
// were struct fields nothing read, and the trust score awarded 30 points for
// `did.starts_with("did:")` — i.e. for typing a string.
/// Outcome of checking a manifest's `signatures` block.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "lowercase")]
pub enum SignatureStatus {
/// `manifest_hash` matches the content and `did_signature` verifies against
/// the key in `author.did`.
Valid,
/// No `signatures` block. Not an attack — an unsigned publisher — but it
/// earns none of the identity-derived trust.
#[default]
Missing,
/// A `signatures` block is present and wrong: corrupt, tampered with, or
/// signed by a key other than the one `author.did` names.
Invalid { reason: String },
}
impl SignatureStatus {
pub fn is_valid(&self) -> bool {
matches!(self, Self::Valid)
}
}
/// Recursively rebuild a JSON value with every object's keys in lexicographic
/// order, so the signed preimage is byte-stable.
///
/// This is not belt-and-braces. `ManifestContainer::env` is a `HashMap`, whose
/// iteration order is randomised per process; and `serde_json::Map` is only a
/// sorted `BTreeMap` while the `preserve_order` feature is off — a feature any
/// crate anywhere in the dependency graph can turn on for everyone through
/// Cargo feature unification. Either way the digest would start changing
/// between runs and every signature would break. Sorting here makes the
/// preimage independent of both.
fn canonicalize(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut pairs: Vec<(String, serde_json::Value)> = map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = serde_json::Map::new();
for (k, v) in pairs {
out.insert(k, canonicalize(v));
}
serde_json::Value::Object(out)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.into_iter().map(canonicalize).collect())
}
other => other,
}
}
/// The exact bytes a manifest signature covers: the manifest as canonical JSON
/// (sorted keys, no whitespace) with the `signatures` block itself omitted —
/// a signature cannot cover the field that holds it.
pub fn canonical_signing_bytes(manifest: &AppManifest) -> Result<Vec<u8>> {
let mut unsigned = manifest.clone();
unsigned.signatures = None;
let value = serde_json::to_value(&unsigned).context("Serializing manifest for signing")?;
serde_json::to_vec(&canonicalize(value)).context("Encoding canonical manifest JSON")
}
/// SHA-256 over [`canonical_signing_bytes`]. This digest is what gets signed,
/// and what `signatures.manifest_hash` records as `sha256:<hex>`.
pub fn manifest_digest(manifest: &AppManifest) -> Result<[u8; 32]> {
use sha2::{Digest, Sha256};
Ok(Sha256::digest(canonical_signing_bytes(manifest)?).into())
}
/// Sign `manifest` in place with an Ed25519 key, filling in `signatures`.
///
/// The caller must ensure `author.did` is the did:key for `signing_key` —
/// [`publish`] enforces that. Signing with a mismatched key produces a manifest
/// that every verifier rejects.
pub fn sign_manifest(
manifest: &mut AppManifest,
signing_key: &ed25519_dalek::SigningKey,
) -> Result<()> {
use ed25519_dalek::Signer;
// Clear first so a re-sign never covers a previous signature.
manifest.signatures = None;
let digest = manifest_digest(manifest)?;
let signature = signing_key.sign(&digest);
manifest.signatures = Some(ManifestSignatures {
manifest_hash: format!("sha256:{}", hex::encode(digest)),
did_signature: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
signature.to_bytes(),
),
});
Ok(())
}
/// Verify a manifest's `signatures` block against its own content and the
/// Ed25519 key encoded in `author.did`.
///
/// Never returns an error: a manifest arriving off a public relay is untrusted
/// input, and every way it can be wrong is a verdict rather than an exception.
pub fn verify_manifest_signature(manifest: &AppManifest) -> SignatureStatus {
use ed25519_dalek::Verifier;
let invalid = |reason: &str| SignatureStatus::Invalid {
reason: reason.to_string(),
};
let sigs = match &manifest.signatures {
Some(s) => s,
None => return SignatureStatus::Missing,
};
let digest = match manifest_digest(manifest) {
Ok(d) => d,
Err(_) => return invalid("manifest could not be canonicalized"),
};
// 1. Content integrity: does the recorded hash describe this manifest?
let claimed_hex = match sigs.manifest_hash.strip_prefix("sha256:") {
Some(h) => h,
None => return invalid("manifest_hash is not in sha256:<hex> form"),
};
match hex::decode(claimed_hex) {
Ok(claimed) if claimed == digest => {}
Ok(_) => return invalid("manifest_hash does not match the manifest content"),
Err(_) => return invalid("manifest_hash is not valid hex"),
}
// 2. Identity: resolve the DID to a key.
let pubkey_bytes = match crate::identity::pubkey_bytes_from_did_key(&manifest.author.did) {
Ok(p) => p,
Err(e) => {
return SignatureStatus::Invalid {
reason: format!("author.did is not a resolvable Ed25519 did:key: {e}"),
}
}
};
let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) {
Ok(k) => k,
Err(_) => return invalid("author.did does not encode a valid Ed25519 key"),
};
// 3. Authenticity: did that key sign this digest?
let sig_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&sigs.did_signature,
) {
Ok(b) => b,
Err(_) => return invalid("did_signature is not valid base64"),
};
let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
Ok(a) => a,
Err(_) => return invalid("did_signature is not a 64-byte Ed25519 signature"),
};
match verifying_key.verify(&digest, &ed25519_dalek::Signature::from_bytes(&sig_array)) {
Ok(()) => SignatureStatus::Valid,
Err(_) => invalid("did_signature does not verify against author.did"),
}
}
/// Cache of discovered marketplace apps.
@@ -256,15 +431,22 @@ pub fn validate_manifest(manifest: &AppManifest) -> Vec<String> {
}
/// Calculate trust score for a discovered app manifest.
/// `signature` gates both identity-derived factors. Pass the result of
/// [`verify_manifest_signature`].
pub fn calculate_trust_score(
manifest: &AppManifest,
relay_count: u32,
federated_dids: &[String],
signature: &SignatureStatus,
) -> (u32, String) {
let mut score: u32 = 0;
// DID verification (30 points) — has a valid DID in author
if !manifest.author.did.is_empty() && manifest.author.did.starts_with("did:") {
// Identity (30 points) — the author proved control of the key their
// did:key names. This used to be `did.starts_with("did:")`, i.e. a string
// test any publisher could pass by typing one, which made the whole
// "Verified" tier meaningless.
let identity_proven = signature.is_valid();
if identity_proven {
score += 30;
}
@@ -275,8 +457,13 @@ pub fn calculate_trust_score(
_ => 20,
};
// Federation trust (20 points) — developer DID in federation
if federated_dids.contains(&manifest.author.did) {
// Federation trust (20 points) — developer DID in federation.
//
// Also gated on the signature: an unverified `author.did` is just a string
// the publisher chose, so without this an attacker could copy a DID the
// user federates with and collect 20 points for impersonating them —
// exactly the peer they trust most.
if identity_proven && federated_dids.contains(&manifest.author.did) {
score += 20;
}
@@ -377,9 +564,28 @@ pub async fn discover(
continue;
}
// Check the DID signature before the manifest is allowed anywhere near
// the cache. A *wrong* signature is not a low-trust manifest, it is a
// corrupt or forged one — drop it rather than listing it at reduced
// score, so it can never be installed. A *missing* signature is
// different: an unsigned publisher is legitimate, just unproven, and
// scores zero on the identity factors.
let signature = verify_manifest_signature(&manifest);
if let SignatureStatus::Invalid { reason } = &signature {
warn!(
app_id = %manifest.app_id,
author_did = %manifest.author.did,
nostr_pubkey = %event.pubkey.to_hex(),
reason = %reason,
"Rejecting marketplace manifest with a bad DID signature"
);
continue;
}
let app_id = manifest.app_id.clone();
let entry = app_map.entry(app_id).or_insert_with(|| {
let (trust_score, trust_tier) = calculate_trust_score(&manifest, 1, federated_dids);
let (trust_score, trust_tier) =
calculate_trust_score(&manifest, 1, federated_dids, &signature);
(
DiscoveredApp {
manifest,
@@ -388,6 +594,7 @@ pub async fn discover(
relay_count: 0,
first_seen: event.created_at.to_human_datetime(),
nostr_pubkey: event.pubkey.to_hex(),
signature,
},
0,
)
@@ -400,7 +607,8 @@ pub async fn discover(
.into_values()
.map(|(mut app, relay_count)| {
app.relay_count = relay_count;
let (score, tier) = calculate_trust_score(&app.manifest, relay_count, federated_dids);
let (score, tier) =
calculate_trust_score(&app.manifest, relay_count, federated_dids, &app.signature);
app.trust_score = score;
app.trust_tier = tier;
app
@@ -440,6 +648,32 @@ pub async fn publish(
}
let identity_dir = data_dir.join("identity");
// Sign with the node's Ed25519 identity key — the same key its did:key
// encodes — so consumers can verify authorship independently of whichever
// Nostr key happens to relay the event.
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir)
.await
.context("Loading node identity to sign the manifest")?;
let our_did = identity.did_key().context("Deriving this node's did:key")?;
let mut manifest = manifest.clone();
if manifest.author.did.is_empty() {
manifest.author.did = our_did.clone();
} else if manifest.author.did != our_did {
// We can only sign as ourselves. Publishing under someone else's DID
// would produce a manifest every verifier rejects, so fail loudly here
// instead of broadcasting garbage to every relay.
anyhow::bail!(
"Cannot publish as author.did {} — this node can only sign as {}",
manifest.author.did,
our_did
);
}
sign_manifest(&mut manifest, identity.signing_key()).context("Signing manifest")?;
debug_assert!(verify_manifest_signature(&manifest).is_valid());
let manifest = &manifest;
let keys = load_or_create_keys(&identity_dir).await?;
let client = build_nostr_client(keys, tor_proxy)?;
@@ -636,21 +870,179 @@ mod tests {
assert!(issues.len() >= 2);
}
/// A real Ed25519 keypair, its did:key, and a manifest signed by it.
fn signed_manifest() -> (ed25519_dalek::SigningKey, String, AppManifest) {
let key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
let did = crate::identity::did_key_from_pubkey_hex(&hex::encode(
key.verifying_key().as_bytes(),
))
.unwrap();
let mut manifest = sample_manifest();
manifest.author.did = did.clone();
sign_manifest(&mut manifest, &key).unwrap();
(key, did, manifest)
}
#[test]
fn verify_accepts_a_properly_signed_manifest() {
let (_key, _did, manifest) = signed_manifest();
assert_eq!(verify_manifest_signature(&manifest), SignatureStatus::Valid);
}
#[test]
fn verify_reports_missing_when_there_is_no_signature_block() {
let manifest = sample_manifest();
assert_eq!(
verify_manifest_signature(&manifest),
SignatureStatus::Missing
);
}
/// Tampering with any covered field must break the hash check.
#[test]
fn verify_rejects_a_tampered_field() {
let (_key, _did, mut manifest) = signed_manifest();
manifest.container.image = "docker.io/evil/backdoor:1.0.0".into();
match verify_manifest_signature(&manifest) {
SignatureStatus::Invalid { reason } => {
assert!(reason.contains("does not match"), "reason: {reason}")
}
other => panic!("tampered manifest accepted: {other:?}"),
}
}
/// The interesting attack: tamper with the content AND recompute
/// `manifest_hash` so the integrity check passes. Without the key the
/// signature can't be regenerated, so this must still fail.
#[test]
fn verify_rejects_tampering_that_also_rewrites_the_hash() {
let (_key, _did, mut manifest) = signed_manifest();
let stolen_signature = manifest.signatures.clone().unwrap().did_signature;
manifest.container.image = "docker.io/evil/backdoor:1.0.0".into();
let new_digest = manifest_digest(&manifest).unwrap();
manifest.signatures = Some(ManifestSignatures {
manifest_hash: format!("sha256:{}", hex::encode(new_digest)),
did_signature: stolen_signature,
});
match verify_manifest_signature(&manifest) {
SignatureStatus::Invalid { reason } => {
assert!(reason.contains("does not verify"), "reason: {reason}")
}
other => panic!("forged manifest accepted: {other:?}"),
}
}
/// Signing with one key while claiming another's DID must fail — this is
/// the impersonation case the whole layer exists to stop.
#[test]
fn verify_rejects_a_signature_from_a_key_other_than_the_claimed_did() {
let (_key_a, _did_a, mut manifest) = signed_manifest();
let (_key_b, did_b, _) = signed_manifest();
manifest.author.did = did_b;
assert!(
!verify_manifest_signature(&manifest).is_valid(),
"a manifest signed by A must not verify as B"
);
}
#[test]
fn verify_rejects_unusable_author_dids_and_malformed_signatures() {
let (key, _did, base) = signed_manifest();
// Not a did:key at all.
let mut m = base.clone();
m.author.did = "did:web:example.com".into();
assert!(!verify_manifest_signature(&m).is_valid());
// did:key shaped but not decodable — the old code scored this 30/30.
let mut m = base.clone();
m.author.did = "did:key:z6MkTest123".into();
assert!(!verify_manifest_signature(&m).is_valid());
// Signature that isn't base64.
let mut m = base.clone();
m.signatures.as_mut().unwrap().did_signature = "not!base64!".into();
assert!(!verify_manifest_signature(&m).is_valid());
// Base64 of the wrong length.
let mut m = base.clone();
m.signatures.as_mut().unwrap().did_signature =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0u8; 16]);
assert!(!verify_manifest_signature(&m).is_valid());
// Hash in the wrong form.
let mut m = base.clone();
m.signatures.as_mut().unwrap().manifest_hash = "deadbeef".into();
assert!(!verify_manifest_signature(&m).is_valid());
// Sanity: the untouched manifest still verifies, so the cases above
// failed for their own reasons and not because the fixture is broken.
let mut ok = base;
sign_manifest(&mut ok, &key).unwrap();
assert!(verify_manifest_signature(&ok).is_valid());
}
/// `env` is a HashMap, whose iteration order is randomised per process. If
/// the preimage were not canonicalised, the same manifest would hash
/// differently between runs and signatures would fail at random.
#[test]
fn digest_is_stable_regardless_of_map_insertion_order() {
let mut a = sample_manifest();
a.container.env.insert("ZEBRA".into(), "1".into());
a.container.env.insert("ALPHA".into(), "2".into());
a.container.env.insert("MIDDLE".into(), "3".into());
let mut b = sample_manifest();
b.container.env.insert("MIDDLE".into(), "3".into());
b.container.env.insert("ALPHA".into(), "2".into());
b.container.env.insert("ZEBRA".into(), "1".into());
assert_eq!(manifest_digest(&a).unwrap(), manifest_digest(&b).unwrap());
assert_eq!(
canonical_signing_bytes(&a).unwrap(),
canonical_signing_bytes(&b).unwrap()
);
}
/// The signature must not cover the field that holds it, or re-signing an
/// already-signed manifest would produce a different digest each time.
#[test]
fn digest_ignores_the_signatures_block() {
let (key, _did, signed) = signed_manifest();
let mut unsigned = signed.clone();
unsigned.signatures = None;
assert_eq!(
manifest_digest(&signed).unwrap(),
manifest_digest(&unsigned).unwrap()
);
// Re-signing is stable (Ed25519 is deterministic).
let mut resigned = signed.clone();
sign_manifest(&mut resigned, &key).unwrap();
assert_eq!(
resigned.signatures.unwrap().did_signature,
signed.signatures.unwrap().did_signature
);
}
#[test]
fn test_trust_score_full() {
let manifest = sample_manifest();
let (score, tier) =
calculate_trust_score(&manifest, 3, &["did:key:z6MkTest123".to_string()]);
// DID (30) + relay consensus 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92
assert!(score >= 80, "Expected verified, got score={}", score);
let (_key, did, manifest) = signed_manifest();
let signature = verify_manifest_signature(&manifest);
let (score, tier) = calculate_trust_score(&manifest, 3, &[did], &signature);
// identity (30) + relays 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92
assert!(score >= 80, "Expected verified, got score={score}");
assert_eq!(tier, "verified");
}
#[test]
fn test_trust_score_no_federation() {
let manifest = sample_manifest();
let (score, tier) = calculate_trust_score(&manifest, 1, &[]);
// DID (30) + 1 relay (5) + no federation (0) + semver (10) + repo (5) + security (15) = 65
let (_key, _did, manifest) = signed_manifest();
let signature = verify_manifest_signature(&manifest);
let (score, tier) = calculate_trust_score(&manifest, 1, &[], &signature);
// identity (30) + 1 relay (5) + semver (10) + repo (5) + security (15) = 65
assert_eq!(tier, "community");
assert!((50..80).contains(&score));
}
@@ -662,8 +1054,49 @@ mod tests {
manifest.repo_url = String::new();
manifest.version = "1".into();
manifest.container.readonly_root = false;
let (score, _tier) = calculate_trust_score(&manifest, 1, &[]);
assert!(score < 50, "Expected low score, got {}", score);
let (score, _tier) =
calculate_trust_score(&manifest, 1, &[], &SignatureStatus::Missing);
assert!(score < 50, "Expected low score, got {score}");
}
/// The regression that made "Verified" meaningless: an unsigned manifest
/// with a plausible-looking DID string used to score 30/30 on identity and
/// land at 65 — "Community" — on nothing but a `starts_with("did:")`.
#[test]
fn an_unsigned_manifest_earns_no_identity_points() {
let manifest = sample_manifest(); // author.did is "did:key:z6MkTest123"
let signature = verify_manifest_signature(&manifest);
assert_eq!(signature, SignatureStatus::Missing);
let (score, tier) = calculate_trust_score(&manifest, 1, &[], &signature);
// 1 relay (5) + semver (10) + repo (5) + security (15) = 35, no identity 30.
assert_eq!(score, 35);
assert_eq!(tier, "unverified");
}
/// Impersonation via the federation factor: claiming a DID the user
/// federates with must earn nothing unless the claim is proven.
#[test]
fn claiming_a_federated_did_without_proving_it_earns_no_federation_points() {
let (_key, victim_did, _) = signed_manifest();
let mut impostor = sample_manifest();
impostor.author.did = victim_did.clone();
assert_eq!(
verify_manifest_signature(&impostor),
SignatureStatus::Missing
);
let federated = [victim_did];
let (unproven, _) =
calculate_trust_score(&impostor, 1, &federated, &SignatureStatus::Missing);
let (proven, _) = calculate_trust_score(&impostor, 1, &federated, &SignatureStatus::Valid);
assert_eq!(
proven - unproven,
50,
"identity (30) + federation (20) must both hang off proof"
);
}
#[test]
@@ -692,6 +1125,7 @@ mod tests {
relay_count: 2,
first_seen: "2026-03-10T00:00:00Z".into(),
nostr_pubkey: "abc123".into(),
signature: SignatureStatus::Valid,
}],
last_updated: "2026-03-10T00:00:00Z".into(),
};
@@ -699,5 +1133,58 @@ mod tests {
let loaded = load_cache(dir.path()).await.unwrap();
assert_eq!(loaded.apps.len(), 1);
assert_eq!(loaded.apps[0].manifest.app_id, "test-app");
assert_eq!(loaded.apps[0].signature, SignatureStatus::Valid);
}
/// A cache written before the signature field existed must still load, and
/// must come back as unverified rather than silently defaulting to trusted.
#[tokio::test]
async fn a_legacy_cache_without_the_signature_field_loads_as_missing() {
let dir = tempfile::tempdir().unwrap();
ensure_dirs(dir.path()).await.unwrap();
let legacy = serde_json::json!({
"apps": [{
"manifest": sample_manifest(),
"trust_score": 75,
"trust_tier": "community",
"relay_count": 2,
"first_seen": "2026-03-10T00:00:00Z",
"nostr_pubkey": "abc123"
}],
"last_updated": "2026-03-10T00:00:00Z"
});
let path = dir
.path()
.join(MARKETPLACE_DIR)
.join("cache")
.join(CACHE_FILE);
fs::write(&path, serde_json::to_vec(&legacy).unwrap())
.await
.unwrap();
let loaded = load_cache(dir.path()).await.unwrap();
assert_eq!(loaded.apps.len(), 1);
assert_eq!(loaded.apps[0].signature, SignatureStatus::Missing);
}
/// `SignatureStatus` crosses the RPC boundary to the UI, so its wire shape
/// is a contract worth pinning.
#[test]
fn signature_status_serialises_to_a_tagged_object() {
assert_eq!(
serde_json::to_value(SignatureStatus::Valid).unwrap(),
serde_json::json!({ "status": "valid" })
);
assert_eq!(
serde_json::to_value(SignatureStatus::Missing).unwrap(),
serde_json::json!({ "status": "missing" })
);
assert_eq!(
serde_json::to_value(SignatureStatus::Invalid {
reason: "nope".into()
})
.unwrap(),
serde_json::json!({ "status": "invalid", "reason": "nope" })
);
}
}
@@ -1030,7 +1030,7 @@ pub(super) async fn run_mesh_session(
// The sequence is raced against the shutdown signal: probes/handshakes
// can take 10s+, and without this a stop() issued mid-probe (config
// change, disable, rename) always burned the full listener-shutdown
// timeout and ended in a hard abort — observed live on archi-dev-box
// timeout and ended in a hard abort — observed live on a test node
// 2026-07-28. Dropping the open future mid-probe is safe: it holds no
// session state yet and the port guard/serial handle close with it.
let open_fut = async {
+2 -2
View File
@@ -2547,8 +2547,8 @@ mod tests {
#[test]
fn test_group_peer_twins_collapses_radio_and_federation() {
let radio = mk_peer(42, "Archy-X250-EXP", Some("ABCD"), false);
let fed = mk_peer(0x8000_0001, "Archy-X250-EXP", Some("abcd"), true);
let radio = mk_peer(42, "Test-Peer-1", Some("ABCD"), false);
let fed = mk_peer(0x8000_0001, "Test-Peer-1", Some("abcd"), true);
let groups = group_peer_twins(&[radio, fed]);
assert_eq!(groups.len(), 1, "twins must collapse to one conversation");
let g = &groups[0];
+2 -2
View File
@@ -134,7 +134,7 @@ fn daemon_program() -> (String, Option<String>) {
/// ship the archipelago binary ahead of the packaged daemon tools, so a fleet
/// node can run a new binary against an old daemon — whose argparse EXITS on
/// an unknown flag, killing the mesh session on every spawn (live regression,
/// framework-pt on v1.7.117). Probe `--help` and only pass the flag when the
/// a test node on v1.7.117). Probe `--help` and only pass the flag when the
/// daemon advertises it; an old daemon then runs edge-only exactly as before.
async fn daemon_supports_enable_transport() -> bool {
let (program, script) = daemon_program();
@@ -602,7 +602,7 @@ impl ReticulumLink {
self.prefix_to_hash.insert(prefix, hash);
// Heal names persisted by pre-2026-07-28 builds, which could
// store a raw `ARCHY:…` identity blob as the display name (seen
// live on archi-dev-box). Blob-only announces assert no name, so
// live on a test node). Blob-only announces assert no name, so
// nothing would ever overwrite it — swap in the placeholder.
let display_name = if p.display_name.starts_with("ARCHY:") {
format!("Reticulum {}", hex::encode(&hash[..4]))
+1 -1
View File
@@ -625,7 +625,7 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
// mtime here each probe/session open minted a "new" plugged_at, the
// UI's (path, plugged_at) dismissal key never matched again, and the
// setup modal re-fired forever on a device that never left the port
// (observed live on archi-dev-box 2026-07-28). btime/ctime only
// (observed live on a test node 2026-07-28). btime/ctime only
// change when udev (re)creates/chowns the node — i.e. on real plugs.
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
m.created()
+35 -2
View File
@@ -5,6 +5,7 @@
//!
//! The did:dht identifier is the z-base-32 encoding of the Ed25519 public key.
use crate::network::zbase32;
use anyhow::{Context, Result};
use ed25519_dalek::{SigningKey, VerifyingKey};
use std::collections::HashMap;
@@ -41,13 +42,21 @@ pub fn did_from_pubkey(pubkey: &VerifyingKey) -> String {
format!("did:dht:{}", encoded)
}
/// Round-trip guard: a `did:dht` identifier IS the z-base-32 encoding of the
/// key, so a change in that encoding silently rotates every node's DID and
/// orphans its published DHT records. Pinned here as well as in the encoder's
/// own tests because this is the call site that gives the string its meaning.
#[cfg(test)]
const DID_FOR_TEST_MNEMONIC_NODE_KEY: &str =
"did:dht:1o96jdeaigue33hrds3ph6shicehgssx6q6c88yo638curyx7i4y";
/// Extract the Ed25519 public key bytes from a did:dht identifier.
pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> {
let id = did
.strip_prefix("did:dht:")
.ok_or_else(|| anyhow::anyhow!("Not a did:dht identifier: {}", did))?;
let bytes = zbase32::decode_full_bytes_str(id)
.map_err(|e| anyhow::anyhow!("Invalid z-base-32: {:?}", e))?;
let bytes =
zbase32::decode_full_bytes_str(id).map_err(|e| anyhow::anyhow!("Invalid z-base-32: {e}"))?;
if bytes.len() != 32 {
anyhow::bail!("Expected 32-byte pubkey, got {} bytes", bytes.len());
}
@@ -188,6 +197,30 @@ mod tests {
assert!(pubkey_from_did("did:key:z123").is_err());
}
/// The identifier for a known key must not move. Guards the LGPL `zbase32`
/// → in-tree encoder swap, and any future change to it.
#[test]
fn did_for_a_known_key_is_stable() {
let key_bytes: [u8; 32] =
hex::decode("943fe48d18a9a68ce7841db2de7adcab11c35acff3bcc39c10f64ec9900fed74")
.unwrap()
.try_into()
.unwrap();
let pubkey = VerifyingKey::from_bytes(&key_bytes).unwrap();
assert_eq!(did_from_pubkey(&pubkey), DID_FOR_TEST_MNEMONIC_NODE_KEY);
assert_eq!(
pubkey_from_did(DID_FOR_TEST_MNEMONIC_NODE_KEY).unwrap(),
key_bytes
);
}
#[test]
fn rejects_a_did_whose_body_is_not_zbase32() {
// `l` and `v` are not in the z-base-32 alphabet.
assert!(pubkey_from_did("did:dht:lllvvv").is_err());
}
#[test]
fn test_build_did_document() {
let key = SigningKey::generate(&mut rand::rngs::OsRng);
+1
View File
@@ -3,3 +3,4 @@ pub mod dns;
pub mod dwn_store;
pub mod dwn_sync;
pub mod router;
pub mod zbase32;
+227
View File
@@ -0,0 +1,227 @@
//! z-base-32 encoding, as used by `did:dht` identifiers.
//!
//! [z-base-32](https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt)
//! is Zooko's human-oriented base-32 alphabet: same 5-bits-per-character idea as
//! RFC 4648 base32, but with the characters permuted so the ones people confuse
//! (`0`/`O`, `1`/`l`/`I`, `2`/`Z`, `v`/`u`) are either absent or arranged to
//! minimise transcription errors, and with no `=` padding.
//!
//! # Why this exists rather than a crate
//!
//! This replaces the `zbase32` crate, which is **LGPL-3.0+** — the only hard
//! copyleft dependency in the Rust graph and a blocker for the MIT release
//! (`docs/LICENSE-COMPLIANCE-AUDIT.md` §2). Statically linking LGPL code into a
//! Rust binary obliges us to ship relinkable objects, which is impractical for
//! a node image. The encoding itself is an alphabet substitution over a bit
//! stream, so an original implementation is a few dozen lines and adds no
//! dependency at all.
//!
//! # Compatibility
//!
//! Output is **byte-identical** to `zbase32 0.1.2`'s `encode_full_bytes` /
//! `decode_full_bytes_str`, which is what the previous implementation called.
//! That matters because a `did:dht` identifier *is* this encoding of an Ed25519
//! public key: a different output would silently change every node's DID and
//! break already-published DHT records. The tests below pin the crate's own
//! documented vectors, the canonical vectors from Zimmermann's spec, and
//! several 32-byte keys.
//!
//! # Bit layout
//!
//! Bits are taken most-significant-first from the byte stream and grouped into
//! 5-bit chunks. When the bit count is not a multiple of 5 the final chunk is
//! padded on the right (low side) with zero bits. A 32-byte key is 256 bits →
//! 52 characters (260 bits), so the last character carries 4 padding bits.
/// The z-base-32 alphabet. Index = 5-bit value.
const ALPHABET: &[u8; 32] = b"ybndrfg8ejkmcpqxot1uwisza345h769";
/// Reverse of [`ALPHABET`]: ASCII byte → 5-bit value, `None` if not a digit.
/// Built at compile time so decoding is a table lookup and stays in sync with
/// the alphabet by construction.
const DECODE_TABLE: [Option<u8>; 256] = {
let mut table = [None; 256];
let mut i = 0;
while i < 32 {
table[ALPHABET[i] as usize] = Some(i as u8);
i += 1;
}
table
};
/// Encode every bit of `data` as z-base-32.
///
/// Equivalent to the `zbase32` crate's `encode_full_bytes`.
pub fn encode_full_bytes(data: &[u8]) -> String {
let bits = data.len() * 8;
// ceil(bits / 5)
let out_len = bits.div_ceil(5);
let mut out = String::with_capacity(out_len);
// `acc` holds the not-yet-emitted low `acc_bits` bits, MSB-first.
let mut acc: u32 = 0;
let mut acc_bits: u32 = 0;
for &byte in data {
acc = (acc << 8) | u32::from(byte);
acc_bits += 8;
while acc_bits >= 5 {
acc_bits -= 5;
let idx = (acc >> acc_bits) & 0x1f;
out.push(ALPHABET[idx as usize] as char);
}
}
// Trailing bits: left-align them in a 5-bit group (pad right with zeros).
if acc_bits > 0 {
let idx = (acc << (5 - acc_bits)) & 0x1f;
out.push(ALPHABET[idx as usize] as char);
}
debug_assert_eq!(out.len(), out_len);
out
}
/// Decode a z-base-32 string, keeping only whole bytes.
///
/// Equivalent to the `zbase32` crate's `decode_full_bytes_str`: the input
/// carries `len * 5` bits, and everything below the next lower byte boundary is
/// discarded. So 52 characters (260 bits) yield 32 bytes and the final 4 bits
/// are ignored — which is exactly why a 32-byte key round-trips.
///
/// Returns `Err` with the offending character if the input is not z-base-32.
pub fn decode_full_bytes_str(s: &str) -> Result<Vec<u8>, String> {
let total_bits = s.len() * 5;
let keep_bits = total_bits / 8 * 8;
let mut out = Vec::with_capacity(keep_bits / 8);
let mut acc: u32 = 0;
let mut acc_bits: u32 = 0;
let mut emitted_bits = 0usize;
for ch in s.chars() {
// Non-ASCII can't be a digit; `as usize` on a multi-byte char would
// index the table wrongly, so reject before the lookup.
let value = u8::try_from(ch as u32)
.ok()
.and_then(|b| DECODE_TABLE[b as usize])
.ok_or_else(|| format!("not a z-base-32 digit: {ch:?}"))?;
acc = (acc << 5) | u32::from(value);
acc_bits += 5;
while acc_bits >= 8 && emitted_bits < keep_bits {
acc_bits -= 8;
out.push(((acc >> acc_bits) & 0xff) as u8);
emitted_bits += 8;
}
}
debug_assert_eq!(out.len(), keep_bits / 8);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// The three doc-test vectors from `zbase32 0.1.2` itself. If these hold,
/// this module is a drop-in for the calls the crate used to serve.
#[test]
fn matches_the_replaced_crates_own_doctests() {
assert_eq!(
encode_full_bytes("Just an arbitrary sentence.".as_bytes()),
"jj4zg7bycfznyam1cjwzehubqjh1yh5fp34gk5udcwzy"
);
assert_eq!(decode_full_bytes_str("qb1ze3m1").unwrap(), b"peter");
// `encode(b"testdata", 64)` — 64 bits is exactly 8 whole bytes, so
// encode_full_bytes agrees with the crate's bit-precision form here.
assert_eq!(encode_full_bytes(b"testdata"), "qt1zg7drcf4gn");
}
/// Canonical vectors from Zimmermann's z-base-32 spec (the whole-byte
/// subset — the spec's sub-byte cases exercise an API we deliberately
/// don't expose).
#[test]
fn matches_the_spec_vectors() {
assert_eq!(encode_full_bytes(&[0xf0, 0xbf, 0xc7]), "6n9hq");
assert_eq!(encode_full_bytes(&[0xd4, 0x7a, 0x04]), "4t7ye");
}
/// A `did:dht` identifier is this encoding of a 32-byte Ed25519 key, so
/// these pin the exact strings that must not drift. Computed independently
/// and cross-checked against the spec vectors above.
#[test]
fn known_answers_for_32_byte_keys() {
let seq: Vec<u8> = (0u8..32).collect();
assert_eq!(
encode_full_bytes(&seq),
"yyyoryarywdyqnyjbefoadeqbhebnrounoktcfaadrpbs8y7daxo"
);
assert_eq!(
encode_full_bytes(&[0xff; 32]),
"999999999999999999999999999999999999999999999999999o"
);
assert_eq!(
encode_full_bytes(&[0x00; 32]),
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
);
// The node Ed25519 public key derived from seed.rs's TEST_MNEMONIC.
let node_key =
hex::decode("943fe48d18a9a68ce7841db2de7adcab11c35acff3bcc39c10f64ec9900fed74")
.unwrap();
assert_eq!(
encode_full_bytes(&node_key),
"1o96jdeaigue33hrds3ph6shicehgssx6q6c88yo638curyx7i4y"
);
}
#[test]
fn a_32_byte_key_is_52_chars_and_round_trips() {
for seed in 0u8..64 {
let key: Vec<u8> = (0u8..32).map(|i| i.wrapping_mul(7).wrapping_add(seed)).collect();
let encoded = encode_full_bytes(&key);
assert_eq!(encoded.len(), 52, "256 bits must encode to 52 characters");
assert_eq!(decode_full_bytes_str(&encoded).unwrap(), key);
}
}
#[test]
fn round_trips_every_length_up_to_a_block() {
for len in 0..40usize {
let data: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ 0x5a).collect();
let encoded = encode_full_bytes(&data);
// decode_full_bytes only recovers whole bytes, and encoding N bytes
// produces ceil(8N/5) chars which always carry at least 8N bits.
assert_eq!(decode_full_bytes_str(&encoded).unwrap(), data, "len {len}");
}
}
#[test]
fn empty_input() {
assert_eq!(encode_full_bytes(&[]), "");
assert_eq!(decode_full_bytes_str("").unwrap(), Vec::<u8>::new());
}
#[test]
fn rejects_non_alphabet_characters() {
// `l`, `v`, `2`, `0` are deliberately absent from the z-base-32
// alphabet — they're the characters it exists to avoid.
for bad in ["l", "v", "2", "0", "A", "yyy!", "yyyé"] {
assert!(
decode_full_bytes_str(bad).is_err(),
"{bad:?} must not decode"
);
}
}
/// The alphabet must stay a permutation of 32 distinct ASCII characters, or
/// the compile-time decode table silently loses entries.
#[test]
fn alphabet_is_32_distinct_ascii_characters() {
let mut seen = std::collections::HashSet::new();
for &c in ALPHABET.iter() {
assert!(c.is_ascii(), "non-ASCII in alphabet");
assert!(seen.insert(c), "duplicate character in alphabet: {c}");
}
assert_eq!(seen.len(), 32);
for (i, &c) in ALPHABET.iter().enumerate() {
assert_eq!(DECODE_TABLE[c as usize], Some(i as u8));
}
}
}
+73
View File
@@ -919,6 +919,79 @@ mod tests {
);
}
/// Known answers for the derivations the test above does NOT pin.
///
/// `test_full_derivation_from_known_mnemonic` only asserts these are
/// mutually distinct, which is satisfied by ANY change to an HKDF info
/// string or BIP-32 path. That left the LND entropy — the seed behind a
/// user's Lightning wallet — and the FIPS mesh transport key with no
/// known-answer coverage at all: silently redefining either broke no test
/// while invalidating every backup verification a user had already done.
///
/// The expected values were produced independently by the Python verifier
/// published in `docs/SEED-VERIFICATION.md`, whose primitives were in turn
/// cross-checked against `bip_utils` and `cryptography`'s own HKDF. If one
/// of these assertions fails, either a derivation changed (and every
/// published backup-verification instruction is now wrong), or the doc and
/// the code have drifted apart — both are release-blocking.
#[test]
fn test_all_derivations_known_answers_vs_python_verifier() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
// HKDF "archipelago/fips/secp256k1/v1"
assert_eq!(
derive_fips_key(&seed).unwrap().public_key().to_hex(),
"31865360f8cfb8bc3d0d5343bf09f1bc5c17b7f0b509120e94cdbed47d203bca",
"FIPS mesh transport key"
);
// HKDF "archipelago/identity/{i}/ed25519/v1"
assert_eq!(
hex::encode(
derive_identity_ed25519(&seed, 0)
.unwrap()
.verifying_key()
.as_bytes()
),
"c0415866117dd570e908e1b178bf1dd8f7b9cdb8eb662353165c6f4877fe222b",
"identity[0] Ed25519"
);
assert_eq!(
hex::encode(
derive_identity_ed25519(&seed, 1)
.unwrap()
.verifying_key()
.as_bytes()
),
"be7a0e1fe2711dedbbba976a70443e0a70e673bcf7b6e6a8fb20d1d2684e5eba",
"identity[1] Ed25519 — pins the index into the info string"
);
// BIP-32 m/44'/1237'/0'/0/{i} (NIP-06)
assert_eq!(
derive_nostr_identity_key(&seed, 0)
.unwrap()
.public_key()
.to_hex(),
"1ca7e48a33d62063f25d79f18617cc892ef064628aaf80af485e41849343ca52",
"identity[0] Nostr (NIP-06)"
);
// BIP-32 m/84'/0'/0'
assert_eq!(
hex::encode(derive_bitcoin_xprv(&seed).unwrap().private_key.secret_bytes()),
"57558e8c90c2e72f0c121d0fb8844bbbe7a872f0065d21b218a990450b9f93be",
"Bitcoin BIP-84 account key"
);
// HKDF "archipelago/lnd/entropy/v1" (16 bytes)
assert_eq!(
hex::encode(derive_lnd_entropy(&seed).unwrap()),
"5c86f10629bd86cdd269b82a76cc51e4",
"LND aezeed entropy"
);
}
#[test]
fn test_release_root_deterministic_and_domain_separated() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
+3 -3
View File
@@ -587,7 +587,7 @@ impl Server {
// FIPS-fast-failing/Tor path so they add us back.
// Without this, a node that joined everyone stays
// invisible to the whole fleet until a manual
// re-add (the "X250-EXP missing everywhere" case).
// re-add (the "peer missing everywhere" case).
let they_list_us =
state.federated_peers.iter().any(|h| h.did == local_did);
if !they_list_us && !local_onion.is_empty() {
@@ -1195,7 +1195,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
// * `auth: local` — host-local BY INTENT. Bridging one makes a
// port reachable from the whole mesh that was deliberately
// never externally reachable: nbxplorer 32838 answered HTTP
// 200 over the mesh with no credential (archi-dev-box
// 200 over the mesh with no credential (a test node
// 2026-08-04) purely because it appeared in the static port
// list below.
//
@@ -1233,7 +1233,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
// install's port-free step (`fuser -k <port>/tcp`) then
// kills THIS daemon, which holds the port — the exact
// cause of installs failing + apps vanishing on
// framework-pt 2026-07-27. No v4 listener → skip; the
// a test node 2026-07-27. No v4 listener → skip; the
// next rescan picks it up once the app is up.
let v4_up = tokio::time::timeout(
std::time::Duration::from_millis(300),
+2 -2
View File
@@ -522,7 +522,7 @@ mod tests {
trust_level: None,
source: None,
mesh_contact_id: Some(42),
lan_address: Some("192.168.1.100:5678".to_string()),
lan_address: Some("192.0.2.100:5678".to_string()),
fips_npub: None,
onion_address: Some("abc123.onion".to_string()),
last_mesh: None,
@@ -536,7 +536,7 @@ mod tests {
);
assert_eq!(
peer.address_for(TransportKind::Lan),
Some("192.168.1.100:5678".to_string())
Some("192.0.2.100:5678".to_string())
);
assert_eq!(
peer.address_for(TransportKind::Tor),
+19 -8
View File
@@ -131,12 +131,19 @@ fn default_mirrors() -> Vec<UpdateMirror> {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Archipelago Foundation".to_string(),
},
// Fallback, tried only if the named origin fails: a node whose DNS
// or clock is wrong (both break TLS) must still be able to update
// itself, and the signature check is what makes either source safe.
// NOT a second server — the SAME host as the entry above, reached by
// IP over plain HTTP instead of by name over TLS. It buys nothing if
// the origin is down; what it recovers is a node whose **DNS is
// broken or whose clock is wrong**, either of which fails TLS while
// plain HTTP still works. Safe because the manifest carries an Ed25519
// signature verified against the pinned release-root anchor, so
// transport integrity is not what protects the update.
//
// Labelled explicitly so the UI cannot imply redundancy it doesn't
// provide. Real redundancy needs a mirror on a different host.
UpdateMirror {
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
label: "Direct (fallback)".to_string(),
label: "Same server, no DNS/TLS".to_string(),
},
]
}
@@ -210,12 +217,16 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Archipelago Foundation".to_string();
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
mirror.label = "Direct (fallback)".to_string();
// Rewritten on every load, so relabelling here reaches nodes that
// already have the old "Direct (fallback)" text saved in their
// update-mirrors.json — the merge below matches on URL, never on
// label, so without this a renamed default would never propagate.
mirror.label = "Same server, no DNS/TLS".to_string();
}
}
// Named origin first, its IP fallback second, anything the operator
// added after that. Ordering matters: the list is tried in order, so a
// stale entry sitting first costs a timeout on every check.
// Named origin first, its same-host IP fallback second, anything the
// operator added after that. Ordering matters: the list is tried in order,
// so a stale entry sitting first costs a timeout on every check.
list.sort_by_key(|m| match m.url.as_str() {
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
+1
View File
@@ -2,6 +2,7 @@
name = "archipelago-container"
version = "0.1.0"
edition = "2021"
license.workspace = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
+7 -7
View File
@@ -557,7 +557,7 @@ pub enum PortAuth {
/// gated any `session` port regardless of `bind`, which meant a node
/// whose manifests had not yet been updated saw the daemon publish
/// Bitcoin's loopback-only RPC on every host address (caught on
/// archi-dev-box 2026-08-03, seconds after deploy). Requiring the
/// a test node 2026-08-03, seconds after deploy). Requiring the
/// manifest to say so means the loopback pin and the daemon takeover
/// ship together, atomically, and a stale manifest fails safe.
Gated,
@@ -1379,8 +1379,8 @@ impl HostFacts {
#[cfg(test)]
pub fn sample() -> Self {
Self {
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
host_ip: "192.0.2.12".to_string(),
host_mdns: "test-node.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-knots".to_string(),
}
@@ -2305,15 +2305,15 @@ app:
secret_env_hash: None,
};
let facts = HostFacts {
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
host_ip: "192.0.2.12".to_string(),
host_mdns: "test-node.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-core".to_string(),
};
let out = c.resolve_derived_env(&facts);
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
assert_eq!(out[1], "INFO=192.168.1.116-2000");
assert_eq!(out[0], "FM_API_URL=ws://test-node.local:8174");
assert_eq!(out[1], "INFO=192.0.2.12-2000");
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
}
+5 -5
View File
@@ -329,7 +329,7 @@ impl PodmanClient {
// disagree. The earlier attempt put this decision in manifest
// data instead, and a node whose manifests lagged the binary
// published Bitcoin's loopback-only RPC across the LAN
// (archi-dev-box, 2026-08-03).
// (test node, 2026-08-03).
//
// A port that already declares a bind is never overridden — that
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
@@ -343,7 +343,7 @@ impl PodmanClient {
// * `bind` is already honoured by every publish path (here and
// in package::install), so a manifest edit needs no code.
// * inference here would cover only THIS path — proven on
// archi-dev-box, where a recreate went through another one and
// a test node, where a recreate went through another one and
// the pin never applied.
// * and inferring from an ABSENT field is what republished
// Bitcoin's loopback RPC across the LAN, and came within one
@@ -742,7 +742,7 @@ impl PodmanClient {
/// Registries we ship with as `--tls-verify=false` because they're internal
/// HTTP mirrors. Add a host:port here only if it's a controlled mirror that
/// the fleet trusts and operators won't ever paste a malicious URL into.
const INSECURE_REGISTRY_HOSTS: &[&str] = &["146.59.87.168:3000"];
const INSECURE_REGISTRY_HOSTS: &[&str] = &["source.archipelago-foundation.org"];
pub fn image_uses_insecure_registry(image: &str) -> bool {
image
@@ -1087,7 +1087,7 @@ mod tests {
#[test]
fn insecure_registry_detection_matches_http_mirrors_only() {
assert!(image_uses_insecure_registry(
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
"source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest"
));
// The legacy Hetzner mirror at 23.182.128.160 was decommissioned and
// is no longer trusted — it must NOT bypass TLS even if a stale
@@ -1106,7 +1106,7 @@ mod tests {
// string into its own URL still has the attacker host in the
// registry-host slot, so it does NOT match.
assert!(!image_uses_insecure_registry(
"evil.example:80/146.59.87.168:3000/lfg2025/x:latest"
"evil.example:80/source.archipelago-foundation.org/lfg2025/x:latest"
));
}
+1
View File
@@ -2,6 +2,7 @@
name = "archipelago-openwrt"
version = "0.1.0"
edition = "2021"
license.workspace = true
description = "OpenWrt gateway integration for Archipelago — TollGate provisioning over SSH/UCI"
[lib]
+1
View File
@@ -2,6 +2,7 @@
name = "archipelago-performance"
version = "0.1.0"
edition = "2021"
license.workspace = true
[dependencies]
tokio = { version = "1", features = ["full"] }
+1
View File
@@ -2,6 +2,7 @@
name = "archipelago-security"
version = "0.1.0"
edition = "2021"
license.workspace = true
[dependencies]
tokio = { version = "1", features = ["full"] }