Compare commits

..
Author SHA1 Message Date
DorianandClaude Opus 4.7 9cf1177b73 release(v1.7.36-alpha): bitcoin-core in App Store + Sovereignty Stack + dynamic catalog URL
- neode-ui/public/assets/img/app-icons/bitcoin-core.svg (NEW): 256×256
  Umbrel community Bitcoin icon sourced from getumbrel.github.io/
  umbrel-apps-gallery/bitcoin/icon.svg. Referenced by the static
  catalog, the curated fallback, and the upstream lfg2025/app-catalog
  entry so every surface shows the same image.
- app-catalog/catalog.json + neode-ui/public/catalog.json: add
  bitcoin-core (v28.4) entry pointing at bitcoin/bitcoin:28.4. Same
  entry pushed to the lfg2025/app-catalog repo on .160 and the local
  gitea mirror so nodes see it without needing a full archipelago
  update. Sovereignty Stack entry added to FEATURED_DEFINITIONS with
  a description that frames it as a Knots alternative, not a rival.
- core/archipelago/src/api/handler/mod.rs: handle_app_catalog_proxy
  is now instance-scoped (&self) and derives its upstream list from
  load_registries — each active container registry contributes one
  `<scheme>://<reg.url>/app-catalog/raw/branch/main/catalog.json` URL
  in priority order (scheme follows tls_verify). When the operator
  switches mirrors in Settings, the App Store now follows. Falls back
  to the legacy hardcoded .160/tx1138 pair only when registry config
  can't be loaded, so the App Store still renders on nodes that
  haven't persisted one yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:06:10 -04:00
DorianandClaude Opus 4.7 a7048f6d8e release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification
- core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh
  and image-recipe/configs/archipelago-doctor.{service,timer} via
  include_str! and sync to disk + enable the timer on every archipelago
  startup. Idempotent (content-hash compare), dev-box symlink guard keeps
  the git checkout untouched, best-effort (warn-only on failure) so
  bootstrap never blocks server readiness. Wired in main.rs as a
  background tokio task.
- scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects
  when the rootless-netns has lost its pasta tap (container-to-container
  still works but outbound DNS/TCP fails) via an nsenter probe into
  aardvark-dns; with a two-probe 10s debounce to rule out transients and
  a host-precheck that bails out if the host itself is offline. When the
  rootless-netns is truly broken, does a graceful podman stop --all /
  start --all so pasta + aardvark-dns rebuild the netns from scratch.
  Bitcoin-knots and every other outbound container recover in one cycle.
- core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs
  can reuse the existing systemd-run escape hatch.
- apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and
  image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned
  with the real container-specs.sh large-disk tune (4 GiB memory cap,
  cpu_limit: 0 so bitcoind can run -par=auto across every core).
- neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button
  + Updating spinner to every app card that has available-update set.
  Wires through serverStore.updatePackage(id) — the same RPC the detail
  view already calls. common.update / common.updating i18n keys added in
  en.json and es.json.
- core/archipelago/src/identity_manager.rs: add create_from_signing_key()
  that mirrors an existing Ed25519 key as a manager-level identity with
  a deterministic id (`node-<pubkey16>`). Idempotent across restarts,
  gets the hex-SVG master avatar.
- core/archipelago/src/server.rs: the auto-create path on first boot now
  mirrors the node's own signing_key (seed-derived on onboarded installs)
  as a "Node" identity instead of generating a random "Default" keypair.
  Once this ships, the DID on the Web5 DID Status card (via node.did
  RPC), the Node entry on the Identities page (via identity.list), and
  the DID used for peer-to-peer connects (via server_info.pubkey) all
  resolve to the same seed-derived pubkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
DorianandClaude Opus 4.7 06feb85aa5 release(v1.7.34-alpha): re-seed onboarding cache + rotating login bg + drop re-login zoom
- useOnboarding.ts: when the backend gives a definitive answer
  (true/false, not a null retry failure), re-seed the
  neode_onboarding_complete localStorage flag accordingly. Fixes the
  case where a user clears site data on an already-onboarded node —
  OnboardingWrapper's useVideoBackground computed reads localStorage
  synchronously, so without this re-seed the intro video would fire
  again on /login even though RootRedirect correctly sent them
  straight to /login.
- OnboardingWrapper.vue: login background now rotates through
  bg-intro-1..6 on each /login mount, with the current index
  persisted to localStorage (neode_login_bg_idx) so subsequent
  logouts advance rather than repeat the same image.
- Dashboard.vue: subsequent-login branch drops the 1.2s showZoomIn
  entirely. Only the first dashboard entry after onboarding plays
  the full zoom + glitch reveal; every re-login now just fades in
  with the welcome typing (~300ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 05:42:52 -04:00
DorianandClaude Opus 4.7 aa0677be57 release(v1.7.33-alpha): onboarding/login UX fixes + PWA cache bust
- useOnboarding.ts: prefer the backend over localStorage when checking
  onboarding completion. The old order (localStorage first) meant any
  browser that had ever onboarded a node would treat every new fresh
  node as already-onboarded and skip the wizard, dumping the user
  straight at the inline set-password form. Backend is now authoritative;
  localStorage stays as the offline fallback.
- OnboardingWrapper.vue: skip the intro video on `/login` once
  `neode_onboarding_complete` is set. Returning logged-out users now
  get the static lock-screen background + glitch overlay instead of
  replaying the full intro on every logout.
- RootRedirect.vue: when the health check fails, only show the full
  BootScreen if the node was never onboarded. For already-onboarded
  nodes (i.e. an OTA-update blip), keep the spinner and poll the
  health endpoint every 2s for up to 60s before falling back to the
  boot screen. Fixes the "fake boot loader" / "server starting up"
  screens flashing on every successful update.
- loginTransition store: new `justCompletedOnboarding` flag distinct
  from `justLoggedIn`. Set true only by the inline setup-password
  flow (handleSetup). Dashboard.vue branches on it: full glitch+zoom
  reveal for the post-onboarding entry, quick zoom + welcome typing
  on every other login (no triple glitch flashes, ~1.2s vs 8s).
- vite.config.ts: bump assets cache from `assets-cache-v2` to
  `assets-cache-v3` so service workers running the previous bundle
  invalidate their cache and pick up the new UI cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:45:33 -04:00
DorianandClaude Opus 4.7 974fce5870 release(v1.7.32-alpha): fix frontend tarball layout + mDNS shutdown hang
- HOTFIX: v1.7.31-alpha's frontend tarball was packaged with a
  `neode-ui/` top-level directory instead of the flat layout v1.7.30
  and earlier used. Nodes that applied v1.7.31 ended up with
  `/opt/archipelago/web-ui/neode-ui/index.html` instead of
  `/opt/archipelago/web-ui/index.html`, and nginx returned 403/500.
  v1.7.32's tarball is built with `tar -C web/dist/neode-ui .` so
  files land directly at web-ui root. Broken nodes auto-heal on this
  update (web-ui dir is replaced).
- transport/lan.rs: add Drop impl that calls ServiceDaemon::shutdown()
  on the mdns_sd daemon. Without this the OS thread it spawns, plus
  the blocking `receiver.recv()` task, keep the tokio runtime alive
  past SIGTERM — long enough for systemd's TimeoutStopSec to SIGKILL
  the service and mark it Failed. Was visible on every update:
  "shut down cleanly" logged, then 15s later systemd forcibly kills.
- main.rs: after logging "Archipelago shut down cleanly", call
  `std::process::exit(0)` explicitly. Belt-and-suspenders against
  any future non-daemon thread creeping in (reqwest resolver pool,
  etc.) and causing the same SIGKILL regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:52:22 -04:00
DorianandClaude Opus 4.7 682b93f2d6 release(v1.7.31-alpha): idempotent IndeedHub install + auto-merge default mirrors/registries + 3rd OVH update mirror
- Backend: install.rs registry reachability probe now strips the
  `host[:port]/namespace` suffix before appending `/v2/` (the Docker
  V2 API lives at the host root, not under the namespace) and accepts
  HTTP 405 in addition to 200/401 as "registry daemon alive". This
  fixes false "unreachable" reports on the Test button for Gitea and
  other registries that protect their /v2/ endpoint.
- Backend: stacks.rs install_indeedhub_stack now force-removes any
  leftover indeedhub-* containers and indeedhub-net before creating
  the stack. A partial install (or the old first-boot stub racing the
  installer) used to leave containers around that blocked re-install
  with "name already in use". Re-running the App Store install now
  self-heals.
- Backend: registry.rs load_registries auto-merges any default
  registry URLs missing from the saved config (appended with priority
  max+10+i, persisted). Lets new default mirrors (e.g. Server 3 OVH)
  roll out to existing nodes without manual config edits. Explicit
  removals still stick — URLs absent from disk AND absent from
  defaults stay gone.
- Backend: update.rs adds DEFAULT_TERTIARY_MIRROR_URL at
  http://146.59.87.168:3000/ (Server 3 OVH) to default_mirrors, with
  the same auto-merge-on-load behavior as registries. Test updated
  for 3-mirror default (.160, tx1138, .168).
- Scripts: dropped the first-boot IndeedHub stub (~38 lines in
  first-boot-containers.sh §8b). It predated the proper stack
  installer, raced it, and was the main source of the name-conflict
  mess the stacks.rs cleanup above now also guards against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:26:09 -04:00
33 changed files with 693 additions and 91 deletions
+9 -1
View File
@@ -1,6 +1,6 @@
{
"version": 2,
"updated": "2026-04-12T00:00:00Z",
"updated": "2026-04-22T00:00:00Z",
"registry": "git.tx1138.com/lfg2025",
"featured": {
"id": "indeedhub",
@@ -18,6 +18,14 @@
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "bitcoin-core", "title": "Bitcoin Core", "version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors", "category": "money", "tier": "optional",
"dockerImage": "bitcoin/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd", "title": "LND", "version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
+5 -5
View File
@@ -1,11 +1,11 @@
app:
id: bitcoin-core
name: Bitcoin Core
version: 24.0.0
version: 28.4.0
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
container:
image: bitcoin/bitcoin:24.0
image: bitcoin/bitcoin:28.4
image_signature: cosign://...
pull_policy: verify-signature
@@ -13,8 +13,8 @@ app:
- storage: 500Gi # Minimum disk space for mainnet
resources:
cpu_limit: 2
memory_limit: 2Gi
cpu_limit: 0 # 0 = unlimited; bitcoind uses -par=auto across all cores
memory_limit: 4Gi # matches container-specs.sh bitcoin-knots large-disk dbcache=4096
disk_limit: 500Gi
security:
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.30-alpha"
version = "1.7.36-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.30-alpha"
version = "1.7.36-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+38 -11
View File
@@ -115,14 +115,41 @@ impl ApiHandler {
/// Server-side fetch of the upstream app catalog so the browser can
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
/// CSP (the fallback IP-port URL isn't in `connect-src`). Tries the
/// upstream URLs in the same order the frontend used, returns the
/// first 2xx response. 15s total timeout.
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
const UPSTREAMS: &[&str] = &[
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json",
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
];
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
/// list is derived from the operator's configured container registries
/// so switching mirrors in Settings changes the App Store source too —
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
/// URL (http or https per `tls_verify`), tried in priority order.
/// If registry config can't be loaded, falls back to the legacy
/// hardcoded pair so the App Store still renders on nodes that haven't
/// persisted a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) =
crate::container::registry::load_registries(&self.config.data_dir).await
{
for reg in config.active_registries() {
let scheme = if reg.tls_verify { "https" } else { "http" };
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
// reg.url already includes the namespace (e.g. "host/lfg2025"),
// so we just tack on the repo + raw path.
upstreams.push(format!(
"{}://{}/app-catalog/raw/branch/main/catalog.json",
scheme, reg.url
));
}
}
if upstreams.is_empty() {
upstreams.push(
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
upstreams.push(
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
@@ -136,8 +163,8 @@ impl ApiHandler {
));
}
};
for url in UPSTREAMS {
match client.get(*url).send().await {
for url in &upstreams {
match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
return Ok(Response::builder()
@@ -408,7 +435,7 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
Self::handle_app_catalog_proxy().await
self.handle_app_catalog_proxy().await
}
// LND connect info — nginx validates session cookie (presence check),
@@ -1589,10 +1589,16 @@ server {
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Registries are configured as `host[:port]/namespace` (for
// example `23.182.128.160:3000/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.
let host = url.split('/').next().unwrap_or(url);
let test_url = if tls_verify {
format!("https://{}/v2/", url)
format!("https://{}/v2/", host)
} else {
format!("http://{}/v2/", url)
format!("http://{}/v2/", host)
};
let client = reqwest::Client::builder()
@@ -1604,8 +1610,11 @@ server {
match client.get(&test_url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
// 200 = open registry, 401 = auth required (both mean it exists)
let reachable = status == 200 || status == 401;
// Accept any "the server responded to HTTP" code as
// reachable — 200 (open registry), 401 (auth required),
// or 405 (server rejects GET but the endpoint exists)
// all mean the registry daemon is alive on the host.
let reachable = status == 200 || status == 401 || status == 405;
Ok(serde_json::json!({
"url": url,
"reachable": reachable,
@@ -937,6 +937,31 @@ impl RpcHandler {
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
}
// Remove any leftover containers from a previous partial install (or
// from the first-boot frontend stub that used to race the installer).
// Without this, `podman run --name indeedhub` fails on name conflict
// and the whole stack install errors out — leaving a half-broken node.
for name in [
"indeedhub",
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
] {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.status()
.await;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "rm", "-f", "indeedhub-net"])
.status()
.await;
// Create indeedhub-net
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "indeedhub-net"])
+152
View File
@@ -0,0 +1,152 @@
//! Bootstrap host-side doctor artifacts on every archipelago startup.
//!
//! The update pipeline swaps the archipelago binary but does not touch
//! scripts or systemd units — those are installed once by the ISO builder.
//! Without this module, changes to `container-doctor.sh` or the doctor
//! service/timer never reach boxes installed before the change.
//!
//! On startup we compare three embedded files against their on-disk
//! copies and rewrite any that differ, then enable the doctor timer if
//! it isn't already. Idempotent: no-ops on boxes that match the
//! embedded version. All work is best-effort — failures are logged but
//! never abort the backend.
use anyhow::{Context, Result};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info, warn};
use crate::update::host_sudo;
const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh");
const DOCTOR_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
const DOCTOR_TIMER: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap the doctor must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
match run().await {
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
Ok(_) => debug!("Doctor artifacts already in sync"),
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
}
}
async fn run() -> Result<bool> {
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
// typically a symlink into the git checkout, and writing through it
// would clobber the working tree with whatever the binary happens to
// have been compiled from. Production ISO installs materialize a real
// directory.
let home_archy = Path::new("/home/archipelago/archy");
if fs::symlink_metadata(home_archy)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
debug!("/home/archipelago/archy is a symlink — skipping doctor bootstrap (dev box)");
return Ok(false);
}
// Skip entirely on machines without the canonical scripts directory —
// writing orphan files there just causes confusion.
let scripts_dir = Path::new(DOCTOR_SH_PATH)
.parent()
.context("doctor script path has no parent")?;
if !scripts_dir.exists() {
debug!(
"Scripts dir {} missing — skipping doctor bootstrap",
scripts_dir.display()
);
return Ok(false);
}
let mut changed = false;
// 1. Script — lives in archipelago's home dir, user-writable.
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
.await
.with_context(|| format!("write {}", DOCTOR_SH_PATH))?;
let _ = tokio::process::Command::new("chmod")
.args(["+x", DOCTOR_SH_PATH])
.status()
.await;
info!("Updated {}", DOCTOR_SH_PATH);
changed = true;
}
// 2. Systemd unit files — /etc is restricted; route through host_sudo.
let service_changed = write_root_if_needed(DOCTOR_SERVICE_PATH, DOCTOR_SERVICE).await?;
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
changed = changed || service_changed || timer_changed;
// 3. Reload + enable. Only when we actually touched units, or when the
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
// the doctor entirely).
let timer_enabled = is_timer_enabled().await;
if service_changed || timer_changed || !timer_enabled {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("daemon-reload failed: {:#}", e);
}
if let Err(e) = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"])
.await
{
warn!("enable archipelago-doctor.timer failed: {:#}", e);
} else if !timer_enabled {
info!("Enabled archipelago-doctor.timer");
}
}
Ok(changed)
}
async fn needs_write(path: &str, expected: &str) -> bool {
match fs::read_to_string(path).await {
Ok(current) => current != expected,
Err(_) => true,
}
}
/// Write content to a root-owned path via `sudo mv` of a user-owned tmp file.
/// Returns true if a write happened.
async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
if !needs_write(path, content).await {
return Ok(false);
}
let tmp = format!(
"/tmp/archipelago-bootstrap-{}-{}.tmp",
std::process::id(),
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unit")
);
fs::write(&tmp, content)
.await
.with_context(|| format!("write tmp {}", tmp))?;
let status = host_sudo(&["mv", &tmp, path])
.await
.with_context(|| format!("sudo mv {} -> {}", tmp, path))?;
if !status.success() {
let _ = fs::remove_file(&tmp).await;
anyhow::bail!("sudo mv to {} exited with {}", path, status);
}
info!("Updated {}", path);
Ok(true)
}
async fn is_timer_enabled() -> bool {
tokio::process::Command::new("systemctl")
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
+33 -2
View File
@@ -97,7 +97,12 @@ fn extract_image_name(image: &str) -> &str {
image.rsplit('/').next().unwrap_or(image)
}
/// Load registry config from disk.
/// Load registry config from disk, merging in any default registries
/// that the operator hasn't explicitly removed. This lets us roll out
/// new default mirrors (e.g. a new Server 3) to existing nodes without
/// them having to edit their saved config. Explicit removals stick —
/// if the URL is absent from disk AND absent from current defaults, it
/// stays gone.
pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
@@ -106,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let content = fs::read_to_string(&path)
.await
.context("Failed to read registry config")?;
let config: RegistryConfig =
let mut config: RegistryConfig =
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
// Migrate: any default registry URL that isn't already in the
// saved list gets appended at the end (so existing priority order
// is preserved for anything the operator already configured).
let defaults = RegistryConfig::default();
let known: std::collections::HashSet<String> =
config.registries.iter().map(|r| r.url.clone()).collect();
let max_priority = config
.registries
.iter()
.map(|r| r.priority)
.max()
.unwrap_or(0);
let mut added = false;
for (i, def) in defaults.registries.iter().enumerate() {
if !known.contains(&def.url) {
let mut cloned = def.clone();
cloned.priority = max_priority.saturating_add(10 + i as u32);
config.registries.push(cloned);
added = true;
}
}
if added {
// Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await;
}
Ok(config)
}
+74
View File
@@ -216,6 +216,80 @@ impl IdentityManager {
Ok(record)
}
/// Mirror an existing Ed25519 signing key as a manager-level identity.
///
/// Used at boot to expose the node's own seed-derived key (the one that
/// backs `server_info.pubkey` and peer-to-peer connections) as an
/// entry in the Identities page, so all three surfaces — DID Status,
/// "Node" entry on Identities, and peer-connect DID — resolve to the
/// same DID. The id is deterministic (`node-<pubkey16>`), so repeated
/// calls on the same key are idempotent: if the file already exists
/// we return the existing record untouched.
pub async fn create_from_signing_key(
&self,
name: String,
purpose: IdentityPurpose,
signing_key: SigningKey,
) -> Result<IdentityRecord> {
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
let id = format!("node-{}", &pubkey_hex[..16]);
// Idempotent: if we already mirrored this key, just return it.
let file_path = self.identities_dir.join(format!("{}.json", id));
if file_path.exists() {
return self.get(&id).await;
}
let created_at = chrono::Utc::now().to_rfc3339();
// Mark as the node (master) identity so it gets the hex SVG.
let default_profile = IdentityProfile {
picture: Some(crate::avatar::default_picture(&pubkey_hex, true)),
..Default::default()
};
let identity_file = IdentityFile {
id: id.clone(),
name: name.clone(),
purpose: purpose.clone(),
secret_key: signing_key.to_bytes().to_vec(),
pubkey_hex: pubkey_hex.clone(),
did: did.clone(),
created_at,
nostr_secret_hex: None,
nostr_pubkey_hex: None,
profile: Some(default_profile),
derivation_index: Some(0),
};
let json = serde_json::to_string_pretty(&identity_file)
.context("Failed to serialize identity")?;
fs::write(&file_path, json.as_bytes())
.await
.context("Failed to write identity file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to set identity file permissions")?;
}
// First identity becomes the default.
let (existing, _) = self.list().await?;
if existing.len() <= 1 {
self.set_default(&id).await?;
}
tracing::info!(
"Mirrored node signing key as Node identity '{}' ({})",
name,
purpose
);
self.get(&id).await
}
/// Create a new identity with keys derived from a BIP-39 master seed.
/// The derivation index is auto-incremented and persisted.
pub async fn create_from_seed(
+16 -1
View File
@@ -28,6 +28,7 @@ mod avatar;
mod backup;
mod bitcoin_rpc;
mod blobs;
mod bootstrap;
mod config;
mod constants;
mod container;
@@ -171,6 +172,11 @@ async fn main() -> Result<()> {
update::run_update_scheduler(update_data_dir).await;
});
// Synchronize host-side doctor artifacts (script + systemd units) with
// what's embedded in this binary. Runs in the background so it never
// delays server readiness; best-effort, warnings only.
tokio::spawn(bootstrap::ensure_doctor_installed());
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
@@ -229,5 +235,14 @@ async fn main() -> Result<()> {
crash_recovery::remove_pid_marker(&config.data_dir).await;
info!("Archipelago shut down cleanly");
Ok(())
// Hard-exit after logging. All business state is persisted by now
// (connections drained, PID marker removed, disk flushes done via
// tokio::fs awaits). Letting tokio try to drop the runtime instead
// can stall for 15s+ on non-daemon OS threads we don't directly
// own (mdns_sd daemon, reqwest resolver pool, etc.) — long enough
// for systemd's TimeoutStopSec to SIGKILL us and mark the service
// Failed, which makes an otherwise-successful update look like a
// crash in `systemctl status`.
std::process::exit(0);
}
+14 -4
View File
@@ -89,22 +89,32 @@ impl Server {
// Load persisted messages (Archipelago channel)
node_message::init(&config.data_dir).await;
// Auto-create default identity if none exist (fresh boot or factory reset)
// Auto-create the Node identity on fresh boot, mirroring the node's
// own signing key (seed-derived when onboarded, random otherwise).
// This keeps the DID shown on the Identities page, the DID Status
// card, and the DID used for peer-to-peer connects all aligned on
// one value — the seed-derived node DID. Idempotent: if the entry
// already exists from a prior boot, create_from_signing_key returns
// the existing record unchanged.
{
let im = crate::identity_manager::IdentityManager::new(&config.data_dir).await;
if let Ok(mgr) = im {
if let Ok((list, _)) = mgr.list().await {
if list.is_empty() {
let signing_key = ed25519_dalek::SigningKey::from_bytes(
&identity.signing_key().to_bytes(),
);
match mgr
.create(
"Default".to_string(),
.create_from_signing_key(
"Node".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
signing_key,
)
.await
{
Ok(record) => {
let _ = mgr.create_nostr_key(&record.id).await;
tracing::info!(did = %record.did, "Auto-created default identity with Nostr key");
tracing::info!(did = %record.did, "Auto-created Node identity mirroring node key");
}
Err(e) => tracing::debug!("Auto-identity creation (non-fatal): {}", e),
}
+12
View File
@@ -160,6 +160,18 @@ impl LanTransport {
}
}
impl Drop for LanTransport {
// The mdns_sd daemon runs on its own OS thread and the browse
// listener task blocks on a sync channel. Without this call both
// keep the process alive past SIGTERM, long enough for systemd to
// SIGKILL us — which makes a normal update look like a crash.
fn drop(&mut self) {
if let Some(daemon) = self.daemon.take() {
let _ = daemon.shutdown();
}
}
}
impl NodeTransport for LanTransport {
fn kind(&self) -> TransportKind {
TransportKind::Lan
+40 -10
View File
@@ -68,6 +68,10 @@ const DEFAULT_UPDATE_MANIFEST_URL: &str =
/// is slow or unreachable.
const DEFAULT_SECONDARY_MIRROR_URL: &str =
"http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// Tertiary mirror on a separate OVH VPS — independent network path so
/// a single-provider outage doesn't knock out all three mirrors.
const DEFAULT_TERTIARY_MIRROR_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@@ -97,13 +101,24 @@ fn default_mirrors() -> Vec<UpdateMirror> {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
UpdateMirror {
url: DEFAULT_TERTIARY_MIRROR_URL.to_string(),
label: "Server 3 (OVH)".to_string(),
},
]
}
/// Load the operator-configured mirror list. Returns defaults if the
/// file doesn't exist yet, so a node OTA'd from a pre-mirrors release
/// starts with both Server 1 and Server 2 available without any manual
/// config.
/// starts with the current default mirrors available without any
/// manual config.
///
/// Migration: any default mirror URL that isn't already in the saved
/// list gets appended at the end. This lets us add new default mirrors
/// (e.g. a new Server 3) and have them appear on existing nodes after
/// an update, without requiring manual config edits. Explicit removals
/// stick — once an operator removes a URL it stays gone unless it's
/// later re-added to defaults.
pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let path = mirrors_path(data_dir);
if !path.exists() {
@@ -112,13 +127,27 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let bytes = fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let list: Vec<UpdateMirror> =
let mut list: Vec<UpdateMirror> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
if list.is_empty() {
Ok(default_mirrors())
} else {
Ok(list)
return Ok(default_mirrors());
}
// Merge in any default URLs the saved config is missing.
let known: std::collections::HashSet<String> =
list.iter().map(|m| m.url.clone()).collect();
let defaults = default_mirrors();
let mut added = false;
for def in &defaults {
if !known.contains(&def.url) {
list.push(def.clone());
added = true;
}
}
if added {
let _ = save_mirrors(data_dir, &list).await;
}
Ok(list)
}
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
@@ -763,7 +792,7 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
/// though sudo itself is root. `systemd-run --wait` spawns a transient
/// service unit that inherits systemd's default protections (i.e. none
/// of ours), escaping the namespace.
async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
@@ -1187,9 +1216,10 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 2);
assert!(list[0].url.contains("git.tx1138.com"));
assert!(list[1].url.contains("23.182.128.160"));
assert_eq!(list.len(), 3);
assert!(list[0].url.contains("23.182.128.160"));
assert!(list[1].url.contains("git.tx1138.com"));
assert!(list[2].url.contains("146.59.87.168"));
}
#[tokio::test]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.6.0-alpha",
"version": "1.7.36-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.6.0-alpha",
"version": "1.7.36-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.6.0-alpha",
"version": "1.7.36-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

+12
View File
@@ -22,6 +22,18 @@
"category": "money",
"tier": "core"
},
{
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"dockerImage": "bitcoin/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin",
"category": "money",
"tier": "optional"
},
{
"id": "lnd",
"title": "LND",
+17 -4
View File
@@ -19,11 +19,24 @@ async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T
}
export async function isOnboardingComplete(): Promise<boolean> {
// localStorage is set on completion and survives backend restarts/resets
if (localStorage.getItem('neode_onboarding_complete') === '1') return true
// Prefer the backend — localStorage gets stale across nodes (a
// browser that onboarded node A would otherwise treat fresh node B
// as already-onboarded and skip the wizard entirely). Only fall
// back to localStorage if the backend is unreachable.
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
if (result !== null) return result
return false
if (result !== null) {
// Re-seed the localStorage cache so non-async consumers
// (OnboardingWrapper's useVideoBackground computed, etc.) see the
// right answer after the user clears site data on an already-
// onboarded node.
if (result) {
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
} else {
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
}
return result
}
return localStorage.getItem('neode_onboarding_complete') === '1'
}
export async function completeOnboarding(): Promise<void> {
+2
View File
@@ -19,6 +19,8 @@
"launch": "Launch",
"starting": "Starting...",
"stopping": "Stopping...",
"update": "Update",
"updating": "Updating...",
"send": "Send",
"sending": "Sending...",
"back": "Back",
+2
View File
@@ -19,6 +19,8 @@
"launch": "Abrir",
"starting": "Iniciando...",
"stopping": "Deteniendo...",
"update": "Actualizar",
"updating": "Actualizando...",
"send": "Enviar",
"sending": "Enviando...",
"back": "Volver",
@@ -10,10 +10,19 @@ describe('useLoginTransitionStore', () => {
it('starts with all flags false', () => {
const store = useLoginTransitionStore()
expect(store.justLoggedIn).toBe(false)
expect(store.justCompletedOnboarding).toBe(false)
expect(store.pendingWelcomeTyping).toBe(false)
expect(store.startWelcomeTyping).toBe(false)
})
it('setJustCompletedOnboarding updates justCompletedOnboarding', () => {
const store = useLoginTransitionStore()
store.setJustCompletedOnboarding(true)
expect(store.justCompletedOnboarding).toBe(true)
store.setJustCompletedOnboarding(false)
expect(store.justCompletedOnboarding).toBe(false)
})
it('setJustLoggedIn updates justLoggedIn', () => {
const store = useLoginTransitionStore()
store.setJustLoggedIn(true)
+13
View File
@@ -4,6 +4,13 @@ import { ref } from 'vue'
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
export const useLoginTransitionStore = defineStore('loginTransition', () => {
const justLoggedIn = ref(false)
/**
* True only when the user just finished the onboarding wizard
* (first password setup), as distinct from a regular re-login.
* Dashboard uses this to decide whether to play the full glitchy
* reveal vs just a quick interface-draw.
*/
const justCompletedOnboarding = ref(false)
/** Show empty welcome block until typing starts (hide static text) */
const pendingWelcomeTyping = ref(false)
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
@@ -13,6 +20,10 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
justLoggedIn.value = value
}
function setJustCompletedOnboarding(value: boolean) {
justCompletedOnboarding.value = value
}
function setPendingWelcomeTyping(value: boolean) {
pendingWelcomeTyping.value = value
}
@@ -24,6 +35,8 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
return {
justLoggedIn,
setJustLoggedIn,
justCompletedOnboarding,
setJustCompletedOnboarding,
pendingWelcomeTyping,
setPendingWelcomeTyping,
startWelcomeTyping,
+9
View File
@@ -117,6 +117,7 @@
@start="actions.startApp"
@stop="actions.stopApp"
@restart="actions.restartApp"
@update="updateApp"
@show-uninstall="showUninstallModal"
/>
</div>
@@ -296,4 +297,12 @@ function goToApp(id: string) {
function launchApp(id: string) {
useAppLauncherStore().openSession(id)
}
async function updateApp(id: string) {
try {
await serverStore.updatePackage(id)
} catch (err) {
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
}
}
</script>
+14 -1
View File
@@ -264,10 +264,13 @@ watch(() => route.path, (newPath) => {
onMounted(() => {
previousRoutePath = route.path
document.body.classList.add('dashboard-active')
if (loginTransition.justLoggedIn) {
if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry
// right after onboarding (one-time event, persists in feel).
playDashboardLoadOomph()
showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false)
loginTransition.setJustLoggedIn(false)
const triggerRevealGlitch = () => {
isGlitching.value = true
@@ -281,6 +284,16 @@ onMounted(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 4000)
} else if (loginTransition.justLoggedIn) {
// Regular re-login no zoom, no glitch. Just land on the
// dashboard and kick off the welcome typing quickly.
playDashboardLoadOomph()
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustLoggedIn(false)
scheduledTimeout(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 300)
}
window.addEventListener('keydown', handleKioskShortcuts)
+1
View File
@@ -408,6 +408,7 @@ async function handleSetup() {
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustCompletedOnboarding(true)
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => {
+41 -2
View File
@@ -86,9 +86,23 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
// Login uses video when coming from splash, or static + glitch when direct
const isLoginRoute = computed(() => route.path === '/login')
// True once onboarding is complete. Used to skip the intro video on
// the /login route so that returning (logged-out) users go straight
// to the screensaver-style static + glitch background instead of
// replaying the full intro every time.
const onboardingDone = computed(() => {
try {
return localStorage.getItem('neode_onboarding_complete') === '1'
} catch {
return false
}
})
// Check if current route should use video background
const useVideoBackground = computed(() => {
return videoBackgroundRoutes.includes(route.path)
if (!videoBackgroundRoutes.includes(route.path)) return false
if (route.path === '/login' && onboardingDone.value) return false
return true
})
// Map each route to a specific background image
@@ -108,7 +122,32 @@ const routeBackgrounds: Record<string, string> = {
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
}
const loginBackground = 'bg-intro-1.jpg'
// Rotate the login background so the lock screen doesn't look
// identical on every logout. Cycles through bg-intro-1..6 using a
// counter persisted to localStorage so subsequent visits advance.
const LOGIN_BACKGROUNDS = [
'bg-intro-1.jpg',
'bg-intro-2.jpg',
'bg-intro-3.jpg',
'bg-intro-4.jpg',
'bg-intro-5.jpg',
'bg-intro-6.jpg',
]
function pickNextLoginBackground(): string {
try {
const raw = localStorage.getItem('neode_login_bg_idx')
const prev = raw !== null ? parseInt(raw, 10) : -1
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
localStorage.setItem('neode_login_bg_idx', String(next))
return LOGIN_BACKGROUNDS[next]!
} catch {
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]!
}
}
const loginBackground = ref(pickNextLoginBackground())
watch(() => route.path, (p) => {
if (p === '/login') loginBackground.value = pickNextLoginBackground()
})
// Restore video time from splash screen for seamless transition
function restoreVideoTime() {
+25 -1
View File
@@ -129,7 +129,31 @@ onMounted(async () => {
return
}
// Server not ready show boot screen (waiting for backend)
// Server not ready. The full BootScreen is meant for a genuine
// cold-start (fresh install), not for the brief blip during an
// OTA update where the backend restarts. If onboarding has already
// completed we just keep the spinner and retry until the server
// responds again.
const wasOnboardedBefore = localStorage.getItem('neode_onboarding_complete') === '1'
if (wasOnboardedBefore) {
log('server down + onboarded → polling without boot screen')
let retries = 0
const maxRetries = 30 // 30 * 2s = 60s before giving up and showing boot screen
const poll = setInterval(async () => {
retries++
if (await quickHealthCheck()) {
clearInterval(poll)
proceedToApp()
return
}
if (retries >= maxRetries) {
clearInterval(poll)
log('server still down after retries → falling back to boot screen')
showBootScreen.value = true
}
}, 2000)
return
}
showBootScreen.value = true
})
</script>
+24
View File
@@ -114,6 +114,29 @@
</div>
<div v-else class="mt-4 flex gap-2">
<!-- Update available -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click.stop="$emit('update', id)"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
:title="`Update to v${pkg['available-update']}`"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.update') }}
</button>
<!-- Updating in progress -->
<span
v-if="pkg.state === 'updating'"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ t('common.updating') }}
</span>
<!-- Launch -->
<button
v-if="canLaunch(pkg)"
@@ -211,6 +234,7 @@ const emit = defineEmits<{
start: [id: string]
stop: [id: string]
restart: [id: string]
update: [id: string]
showUninstall: [id: string, pkg: PackageDataEntry]
}>()
@@ -77,6 +77,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
export function getCuratedAppList(): MarketplaceApp[] {
return [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '1.13.7', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: `${R}/btcpayserver:1.13.7`, repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
@@ -164,6 +165,11 @@ export const FEATURED_DEFINITIONS: {
desc: 'The foundation of sovereignty. Run a full Bitcoin node to validate every transaction yourself. No trusted third parties. No asking permission. Your node enforces the consensus rules that protect your wealth. Don\'t trust — verify.',
tag: 'FULL VALIDATION // ZERO TRUST',
},
{
id: 'bitcoin-core',
desc: 'The reference Bitcoin implementation. Same full-node guarantees as Knots, tracking upstream releases from the Bitcoin Core maintainers. Pick this if you\'d rather run mainline Bitcoin Core than Knots — both validate every block themselves.',
tag: 'REFERENCE CLIENT // ZERO TRUST',
},
{
id: 'lnd',
desc: 'Lightning-fast payments over the Lightning Network. Open channels, route transactions, and earn routing fees — all from your sovereign node. Instant settlement. Near-zero fees. The future of money, running on your hardware.',
+1 -1
View File
@@ -94,7 +94,7 @@ export default defineConfig({
urlPattern: /\/assets\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'assets-cache-v2',
cacheName: 'assets-cache-v3',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
+67 -1
View File
@@ -367,7 +367,72 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
}
# ── Fix 8: Restart stopped core containers ──────────────────
# ── Fix 8: Rootless netns egress lost ────────────────────────
# Rootless podman uses pasta to give containers internet egress. If pasta's
# tap vanishes (host link flap, mount churn), the rootless-netns keeps inter-
# container traffic working but silently loses outbound. Bitcoin IBD stalls
# at 0 peers; package pulls fail. The only reliable repair is a stop-all/
# start-all cycle so pasta + aardvark-dns rebuild the netns from scratch.
fix_rootless_netns_egress() {
local archi_uid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
# Locate the rootless-netns via aardvark-dns (it lives inside it).
local aardvark_pid
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
# Host precheck: if the host itself can't reach the internet, no point
# cycling containers — this is an upstream problem.
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
return 1
fi
# Probe egress from inside the rootless-netns. One probe is noisy;
# require two consecutive failures 10s apart to rule out transients.
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
return 1 # first probe succeeded
fi
sleep 10
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
return 1 # recovered on its own
fi
log "Rootless-netns egress is broken (host online, container netns unreachable) — cycling"
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
local running
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
if [ -z "$running" ]; then
log " No running containers to cycle — skipping"
return 1
fi
local count
count=$(echo "$running" | wc -l)
log " Stopping $count running containers (graceful, 30s)..."
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
sleep 5
log " Starting containers back up..."
for c in $running; do
$PODMANCMD start "$c" >/dev/null 2>&1 &
done
wait
sleep 5
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log " Rootless-netns egress restored ($count containers cycled)"
else
log " WARN: egress still broken after cycle — may need manual intervention"
fi
return 0
}
# ── Fix 9: Restart stopped core containers ──────────────────
# Rootless Podman 4.x restart policies don't auto-restart on crash.
# This check restarts any exited core containers (tiers 0-2).
fix_stopped_core_containers() {
@@ -414,6 +479,7 @@ run_fix "tor-permissions" fix_tor_permissions
run_fix "searxng" fix_searxng
run_fix "bitcoin-txindex" fix_bitcoin_txindex
run_fix "exit-127" fix_exit_127
run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers
echo ""
-38
View File
@@ -1216,44 +1216,6 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
fi
fi
# 8b. Indeehub (pull from registry, or use local build)
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
# Use image-versions.sh variable if sourced, otherwise detect
if [ -z "${INDEEDHUB_IMAGE:-}" ]; then
INDEEDHUB_IMAGE=""
# Try local image first (pre-built or loaded from ISO)
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'localhost/indeedhub'; then
INDEEDHUB_IMAGE="localhost/indeedhub:local"
# Try pinned registry image
elif $DOCKER pull "$ARCHY_REGISTRY/indeedhub:1.0.0" --tls-verify=false 2>>"$LOG"; then
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
fi
fi
if [ -n "$INDEEDHUB_IMAGE" ]; then
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
$DOCKER run -d --name indeedhub --restart unless-stopped \
--network archy-net --network-alias indeedhub \
--health-cmd="curl -sf http://localhost:7777/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit indeedhub) \
--cap-drop ALL --security-opt no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 7778:7777 \
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
# Fix IndeedHub for iframe: remove X-Frame-Options so it loads in Archipelago panel
sleep 2
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
$DOCKER exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
# Fix Host header for NIP-98 auth — $host strips port, $http_host preserves it
$DOCKER exec indeedhub sed -i 's|proxy_set_header Host $host;|proxy_set_header Host $http_host;|g' /etc/nginx/conf.d/default.conf 2>/dev/null || true
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
$DOCKER cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
fi
$DOCKER exec indeedhub nginx -s reload 2>/dev/null || true
log "Applied IndeedHub iframe fix (X-Frame-Options, Host header, nostr-provider)"
fi
fi
fi
# 9. Custom UI containers (bitcoin-ui, lnd-ui)
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.