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:
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user