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:
Generated
+1
-8
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}];
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod dns;
|
||||
pub mod dwn_store;
|
||||
pub mod dwn_sync;
|
||||
pub mod router;
|
||||
pub mod zbase32;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "archipelago-container"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "archipelago-performance"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "archipelago-security"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
Reference in New Issue
Block a user