Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1bcf271f9 | ||
|
|
85417de952 | ||
|
|
b8d084368e | ||
|
|
36a6101026 | ||
|
|
cfc98c600e | ||
|
|
e206e1fc94 | ||
|
|
9cf1177b73 | ||
|
|
a7048f6d8e | ||
|
|
06feb85aa5 | ||
|
|
aa0677be57 | ||
|
|
974fce5870 | ||
|
|
682b93f2d6 | ||
|
|
18f0929614 |
@@ -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": "docker.io/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.",
|
||||
@@ -102,14 +110,6 @@
|
||||
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng"
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
|
||||
"description": "Your own Nostr relay. Store events locally, relay for friends.",
|
||||
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
|
||||
"author": "scsiblade", "category": "nostr",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
|
||||
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
|
||||
},
|
||||
{
|
||||
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
|
||||
"description": "Federated Bitcoin mint with privacy through federated guardians.",
|
||||
@@ -182,30 +182,6 @@
|
||||
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||
},
|
||||
{
|
||||
"id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7",
|
||||
"description": "Tailscale-style mesh VPN with Nostr control plane.",
|
||||
"icon": "/assets/img/app-icons/nostr-vpn.svg",
|
||||
"author": "Martti Malmi", "category": "networking",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nostr-vpn:v0.3.7",
|
||||
"repoUrl": "https://github.com/mmalmi/nostr-vpn"
|
||||
},
|
||||
{
|
||||
"id": "fips", "title": "FIPS", "version": "0.1.0",
|
||||
"description": "Free Internetworking Peering System. Encrypted mesh network.",
|
||||
"icon": "/assets/img/app-icons/fips.svg",
|
||||
"author": "Jim Corgan", "category": "networking",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/fips:v0.1.0",
|
||||
"repoUrl": "https://github.com/jmcorgan/fips"
|
||||
},
|
||||
{
|
||||
"id": "routstr", "title": "Routstr", "version": "0.4.3",
|
||||
"description": "Decentralized AI inference proxy with Cashu ecash.",
|
||||
"icon": "/assets/img/app-icons/routstr.svg",
|
||||
"author": "Routstr", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/routstr:v0.4.3",
|
||||
"repoUrl": "https://github.com/routstr/routstr-core"
|
||||
},
|
||||
{
|
||||
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
|
||||
"description": "Own your data with DID-based access control.",
|
||||
@@ -222,14 +198,6 @@
|
||||
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
|
||||
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
|
||||
},
|
||||
{
|
||||
"id": "penpot", "title": "Penpot", "version": "2.4",
|
||||
"description": "Open-source design platform. Self-hosted Figma alternative.",
|
||||
"icon": "/assets/img/app-icons/penpot.webp",
|
||||
"author": "Penpot", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/penpot-frontend:2.4",
|
||||
"repoUrl": "https://github.com/penpot/penpot"
|
||||
},
|
||||
{
|
||||
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
|
||||
@@ -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,5 +0,0 @@
|
||||
# Penpot - uses official image
|
||||
FROM penpot/penpot:latest
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,51 +0,0 @@
|
||||
app:
|
||||
id: penpot
|
||||
name: Penpot
|
||||
version: 2.0.0
|
||||
description: Open-source design and prototyping platform. Design tools for teams.
|
||||
|
||||
container:
|
||||
image: penpotapp/frontend:2.13.3
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: penpot
|
||||
|
||||
ports:
|
||||
- host: 8089
|
||||
container: 80
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/penpot
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- PENPOT_PUBLIC_URI=http://localhost:8089
|
||||
- PENPOT_DATABASE_URI=postgresql://penpot:penpot@penpot-db:5432/penpot
|
||||
- PENPOT_REDIS_URI=redis://penpot-redis:6379
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8089
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.29-alpha"
|
||||
version = "1.7.41-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.29-alpha"
|
||||
version = "1.7.41-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -483,7 +483,30 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
|
||||
"bitcoin-core" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
// Vanilla bitcoin/bitcoin image has no entrypoint wrapper and reads
|
||||
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
|
||||
// carries rpcauth; we inject the networking flags as CLI args so
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
Some(vec![
|
||||
"-server=1".to_string(),
|
||||
"-rpcbind=0.0.0.0".to_string(),
|
||||
"-rpcallowip=0.0.0.0/0".to_string(),
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
]),
|
||||
),
|
||||
"bitcoin" | "bitcoin-knots" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
|
||||
@@ -86,9 +86,6 @@ impl RpcHandler {
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
}
|
||||
if package_id == "penpot" || package_id == "penpot-frontend" {
|
||||
return self.install_penpot_stack().await;
|
||||
}
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver" | "btcpay") {
|
||||
return self.install_btcpay_stack().await;
|
||||
}
|
||||
@@ -312,11 +309,6 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// TUN device for mesh networking apps
|
||||
if matches!(package_id, "nostr-vpn" | "fips") {
|
||||
run_args.push("--device=/dev/net/tun");
|
||||
}
|
||||
|
||||
// Create data directories (mkdir only — chown happens AFTER config files are written)
|
||||
for volume in &volumes {
|
||||
if let Some(host_path) = volume.split(':').next() {
|
||||
@@ -358,36 +350,6 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-install: write Nostr identity key files for headless Nostr-aware apps
|
||||
if matches!(package_id, "nostr-vpn" | "fips") {
|
||||
let nostr_secret =
|
||||
std::fs::read_to_string("/var/lib/archipelago/identity/nostr_secret")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
if !nostr_secret.is_empty() {
|
||||
let key_dir = match package_id {
|
||||
"nostr-vpn" => "/var/lib/archipelago/nostr-vpn",
|
||||
"fips" => "/var/lib/archipelago/fips/config",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let key_path = match package_id {
|
||||
"nostr-vpn" => format!("{}/nostr_secret", key_dir),
|
||||
"fips" => format!("{}/fips.key", key_dir),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
tokio::fs::create_dir_all(key_dir).await.ok();
|
||||
tokio::fs::write(&key_path, &nostr_secret).await.ok();
|
||||
// Restrict permissions on key file
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(&key_path, perms).ok();
|
||||
}
|
||||
info!("Wrote Nostr identity key for {}", package_id);
|
||||
}
|
||||
}
|
||||
|
||||
// NOW chown data directories to container UID (after all config files are written)
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
@@ -635,47 +597,32 @@ impl RpcHandler {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Single image pull attempt with progress streaming.
|
||||
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
||||
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
||||
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
// Rewrite to the primary registry's URL so the first attempt
|
||||
// honors the operator's mirror choice (default: VPS) instead of
|
||||
// blindly using whatever registry the image was hardcoded to.
|
||||
// If the rewritten URL fails, pull_from_registries_with_skip
|
||||
// falls through to the other configured registries.
|
||||
let (primary_url, primary_tls) =
|
||||
crate::container::registry::primary_image_url(&self.config.data_dir, docker_image)
|
||||
.await;
|
||||
if primary_url != docker_image {
|
||||
debug!("Rewrote {} → {} for primary registry", docker_image, primary_url);
|
||||
}
|
||||
|
||||
let mut pull_args = vec!["pull".to_string(), primary_url.clone()];
|
||||
if !primary_tls {
|
||||
/// Pull one image URL with live progress streamed through
|
||||
/// `update_install_progress`. Returns Ok(true) on a successful pull,
|
||||
/// Ok(false) on transient failure (so the caller can try the next
|
||||
/// mirror), Err only for unrecoverable setup errors.
|
||||
async fn pull_one_url_with_progress(
|
||||
&self,
|
||||
url: &str,
|
||||
tls_verify: bool,
|
||||
package_id: &str,
|
||||
user_tmp: &str,
|
||||
) -> Result<bool> {
|
||||
let mut pull_args = vec!["pull".to_string(), url.to_string()];
|
||||
if !tls_verify {
|
||||
pull_args.push("--tls-verify=false".to_string());
|
||||
}
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(&pull_args)
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.env("TMPDIR", user_tmp)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
|
||||
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
|
||||
// to pull. 60s was too short and caused premature retries on slow registries.
|
||||
// 10-minute per-URL budget — large layers (Minio, Postgres,
|
||||
// ffmpeg) regularly take several minutes and we'd rather wait
|
||||
// than bounce to the next mirror mid-download.
|
||||
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
@@ -693,50 +640,118 @@ impl RpcHandler {
|
||||
})
|
||||
.await;
|
||||
|
||||
let primary_failed = match pull_result {
|
||||
Ok(Ok(status)) => !status.success(),
|
||||
match pull_result {
|
||||
Ok(Ok(status)) => Ok(status.success()),
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Image pull process error: {}", e);
|
||||
true
|
||||
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Image pull timed out after 60s: {}", primary_url);
|
||||
tracing::warn!("Image pull timed out after 600s: {}", url);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
true
|
||||
Ok(false)
|
||||
}
|
||||
};
|
||||
if !primary_failed && primary_url != docker_image {
|
||||
// Primary pull succeeded but used a rewritten URL. Tag under
|
||||
// the original image reference so downstream code (images -q,
|
||||
// run -d docker_image, etc.) finds it.
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["tag", &primary_url, docker_image])
|
||||
.output()
|
||||
.await;
|
||||
tracing::info!("Pulled {} from primary registry ({})", docker_image, primary_url);
|
||||
}
|
||||
if primary_failed {
|
||||
// Primary failed — walk the remaining configured registries.
|
||||
// Skip primary_url so we don't retry what just failed.
|
||||
match crate::container::registry::pull_from_registries_with_skip(
|
||||
&self.config.data_dir,
|
||||
docker_image,
|
||||
&user_tmp,
|
||||
Some(&primary_url),
|
||||
)
|
||||
}
|
||||
|
||||
/// Pull a container image, trying each configured registry in
|
||||
/// priority order and streaming progress during every attempt. The
|
||||
/// primary is tried first; if it doesn't have the image (or 404's),
|
||||
/// the next mirror is tried — with its own progress streaming, so
|
||||
/// the UI doesn't freeze at 0% after a primary miss. On success the
|
||||
/// image is tagged under `docker_image` so downstream commands
|
||||
/// (images -q, run -d, etc.) can find it by its canonical name.
|
||||
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
// Set TMPDIR to user-writable location — rootless podman's user namespace
|
||||
// makes /var/tmp read-only, which causes `podman pull` to fail with
|
||||
// "mkdir /var/tmp/container_images_storage...: read-only file system"
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
// Build the ordered candidate list: every enabled registry
|
||||
// (highest priority first), each rewriting the image URL to its
|
||||
// own origin. Deduplicate — two registries that happen to share
|
||||
// a URL should only be tried once.
|
||||
let config = crate::container::registry::load_registries(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut tried: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut candidates: Vec<(String, bool)> = Vec::new();
|
||||
for reg in config.active_registries() {
|
||||
let url = config.rewrite_image(docker_image, reg);
|
||||
if tried.insert(url.clone()) {
|
||||
candidates.push((url, reg.tls_verify));
|
||||
}
|
||||
}
|
||||
// Always include the literal URL as a last-resort candidate —
|
||||
// internal mirrors may not host every third-party upstream image
|
||||
// (e.g. docker.io/bitcoin/bitcoin:28.4), and we don't want
|
||||
// "app not mirrored" to masquerade as a generic pull failure.
|
||||
if tried.insert(docker_image.to_string()) {
|
||||
candidates.push((docker_image.to_string(), true));
|
||||
}
|
||||
|
||||
// Walk candidates, streaming progress for each attempt.
|
||||
let mut pulled_url: Option<String> = None;
|
||||
let attempts = candidates.len();
|
||||
for (i, (url, tls_verify)) in candidates.iter().enumerate() {
|
||||
if url != docker_image {
|
||||
debug!("Attempt {}/{}: {}", i + 1, attempts, url);
|
||||
} else {
|
||||
debug!("Attempt {}/{}: {} (literal)", i + 1, attempts, url);
|
||||
}
|
||||
// Reset progress at the top of each attempt so the UI reflects
|
||||
// the fresh pull instead of showing stale bytes from a prior
|
||||
// partial attempt.
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
match self
|
||||
.pull_one_url_with_progress(url, *tls_verify, package_id, &user_tmp)
|
||||
.await?
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!("Pulled {} via fallback registry", docker_image);
|
||||
true => {
|
||||
tracing::info!("Pulled {} from {}", docker_image, url);
|
||||
pulled_url = Some(url.clone());
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Image pull failed: {}", e));
|
||||
false => {
|
||||
tracing::debug!(
|
||||
"Pull attempt {}/{} failed for {}, trying next mirror",
|
||||
i + 1,
|
||||
attempts,
|
||||
url
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify image exists locally after pull
|
||||
let Some(pulled_url) = pulled_url else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Image pull failed from all {} configured registries for {}",
|
||||
attempts,
|
||||
docker_image
|
||||
));
|
||||
};
|
||||
|
||||
// Tag under the original docker_image reference if the successful
|
||||
// pull came from a rewritten URL — downstream code (images -q,
|
||||
// run -d docker_image, etc.) needs to find it by its canonical
|
||||
// name regardless of which mirror actually served the bytes.
|
||||
if pulled_url != docker_image {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["tag", &pulled_url, docker_image])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// Verify image exists locally after pull.
|
||||
let verify = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
@@ -763,7 +778,7 @@ impl RpcHandler {
|
||||
"grafana" => 472,
|
||||
"lnd" => 1000,
|
||||
"mariadb" | "mysql" | "mysql-mempool" | "archy-mempool-db" => 999,
|
||||
"postgres" | "btcpay-postgres" | "immich-postgres" | "penpot-postgres"
|
||||
"postgres" | "btcpay-postgres" | "immich-postgres"
|
||||
| "archy-btcpay-db" | "nextcloud-db" => 70,
|
||||
"electrumx" | "electrs" => 1000,
|
||||
_ => 0, // Most containers run as root (UID 0)
|
||||
@@ -823,6 +838,24 @@ impl RpcHandler {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
// Idempotent: once bitcoin-knots (or a prior install) has started,
|
||||
// the data dir is chowned into the container's user namespace
|
||||
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
|
||||
// daemon can no longer stat or write there. Treat any non-NotFound
|
||||
// error on the conf as "conf already provisioned by the container
|
||||
// user" and skip. Matches the lnd.conf behavior below.
|
||||
match tokio::fs::metadata(&conf_path).await {
|
||||
Ok(_) => {
|
||||
info!("bitcoin.conf already exists, skipping write");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => {
|
||||
info!("bitcoin.conf path inaccessible (container-owned data dir), skipping write");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let salt_bytes: [u8; 16] = rand::random();
|
||||
@@ -833,12 +866,14 @@ impl RpcHandler {
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
prune=550\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
rpcport=8332\n\
|
||||
@@ -1306,20 +1341,6 @@ server {
|
||||
"electrs-ui",
|
||||
)]
|
||||
}
|
||||
"nostr-vpn" => {
|
||||
vec![(
|
||||
"archy-nostr-vpn-ui",
|
||||
"/opt/archipelago/docker/nostr-vpn-ui",
|
||||
"nostr-vpn-ui",
|
||||
)]
|
||||
}
|
||||
"fips" => {
|
||||
vec![(
|
||||
"archy-fips-ui",
|
||||
"/opt/archipelago/docker/fips-ui",
|
||||
"fips-ui",
|
||||
)]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
@@ -1539,10 +1560,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()
|
||||
@@ -1554,8 +1581,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,
|
||||
|
||||
@@ -28,6 +28,18 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the uninstall stage label so the UI can show what's happening
|
||||
/// instead of a generic spinner. Each call broadcasts a state change
|
||||
/// — call sparingly (one per pipeline phase, not per container).
|
||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
@@ -81,6 +93,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,28 @@ impl RpcHandler {
|
||||
if containers_to_remove.is_empty() {
|
||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||
}
|
||||
let total = containers_to_remove.len();
|
||||
|
||||
let mut stopped = 0u32;
|
||||
let mut removed = 0u32;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for name in &containers_to_remove {
|
||||
self.set_uninstall_stage(
|
||||
package_id,
|
||||
&if total > 0 {
|
||||
format!("Stopping containers (0/{})", total)
|
||||
} else {
|
||||
"Cleaning up".to_string()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
for (i, name) in containers_to_remove.iter().enumerate() {
|
||||
self.set_uninstall_stage(
|
||||
package_id,
|
||||
&format!("Stopping containers ({}/{})", i + 1, total),
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||
@@ -326,6 +342,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
self.set_uninstall_stage(package_id, "Cleaning up volumes").await;
|
||||
// Clean up dangling volumes associated with removed containers
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
@@ -354,6 +371,7 @@ impl RpcHandler {
|
||||
|
||||
// Clean data directories unless preserve_data
|
||||
if !preserve_data {
|
||||
self.set_uninstall_stage(package_id, "Removing app data").await;
|
||||
let data_dirs = get_data_dirs_for_app(package_id);
|
||||
for dir in &data_dirs {
|
||||
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||
|
||||
@@ -273,234 +273,6 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend).
|
||||
pub(super) async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"penpot-frontend",
|
||||
"penpot",
|
||||
&[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
"penpot-backend",
|
||||
"penpot-exporter",
|
||||
"penpot-frontend",
|
||||
],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
let images = [
|
||||
"git.tx1138.com/lfg2025/postgres:15",
|
||||
"git.tx1138.com/lfg2025/valkey:8.1",
|
||||
"git.tx1138.com/lfg2025/penpot-backend:2.4",
|
||||
"git.tx1138.com/lfg2025/penpot-exporter:2.4",
|
||||
"git.tx1138.com/lfg2025/penpot-frontend:2.4",
|
||||
];
|
||||
for img in &images {
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "penpot-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Generate a stable secret key derived from the data directory
|
||||
let secret = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"penpot-secret-");
|
||||
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
let host_ip = &self.config.host_ip;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-postgres",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-postgres",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U penpot || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_DB=penpot",
|
||||
"-e",
|
||||
"POSTGRES_USER=penpot",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=penpot",
|
||||
"git.tx1138.com/lfg2025/postgres:15",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-valkey",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-valkey",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=192m",
|
||||
"--pids-limit=2048",
|
||||
"--health-cmd=valkey-cli ping || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-e",
|
||||
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||
"git.tx1138.com/lfg2025/valkey:8.1",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-backend",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-backend",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
"PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
|
||||
"-e",
|
||||
"PENPOT_DATABASE_USERNAME=penpot",
|
||||
"-e",
|
||||
"PENPOT_DATABASE_PASSWORD=penpot",
|
||||
"-e",
|
||||
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||
"-e",
|
||||
"PENPOT_OBJECTS_STORAGE_BACKEND=fs",
|
||||
"-e",
|
||||
"PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
|
||||
"-e",
|
||||
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||
"git.tx1138.com/lfg2025/penpot-backend:2.4",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-exporter",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-exporter",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=2048",
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
"PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
|
||||
"-e",
|
||||
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||
"git.tx1138.com/lfg2025/penpot-exporter:2.4",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-frontend",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-frontend",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=2048",
|
||||
"-p",
|
||||
"9001:8080",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
"-e",
|
||||
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||
"git.tx1138.com/lfg2025/penpot-frontend:2.4",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start penpot-frontend")?;
|
||||
|
||||
if !run.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to start Penpot frontend: {}",
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
info!("Penpot stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "penpot",
|
||||
"message": "Penpot stack installed and started"
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
|
||||
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
|
||||
@@ -937,6 +709,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"])
|
||||
|
||||
@@ -185,12 +185,32 @@ impl AuthManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: user.json
|
||||
Ok(self
|
||||
.get_user()
|
||||
.await?
|
||||
.map(|u| u.onboarding_complete)
|
||||
.unwrap_or(false))
|
||||
// Fallback: user.json. A node that has a password set AND
|
||||
// setup_complete=true has been through onboarding by
|
||||
// definition — you can't reach the password-set step any
|
||||
// other way. The separate `onboarding_complete` flag can drift
|
||||
// out of sync (e.g. the completion RPC never reached disk, or
|
||||
// the node was seeded from a backup pre-dating the flag), so
|
||||
// auto-heal by inferring from setup_complete + password_hash.
|
||||
// Without this, a fully-onboarded node whose `onboarding_complete`
|
||||
// is stuck false will force its user back through the intro
|
||||
// wizard on every cleared browser cache.
|
||||
if let Some(u) = self.get_user().await? {
|
||||
if u.onboarding_complete {
|
||||
return Ok(true);
|
||||
}
|
||||
if u.setup_complete && !u.password_hash.is_empty() {
|
||||
// Persist the healed state so subsequent calls skip this
|
||||
// inference. Ignore write errors — returning true is
|
||||
// still correct even if we can't persist.
|
||||
let healed = OnboardingState { complete: true };
|
||||
if let Ok(json) = serde_json::to_string_pretty(&healed) {
|
||||
let _ = fs::write(&onboarding_file, json).await;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if 2FA is enabled for the user.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Bootstrap host-side artifacts on every archipelago startup.
|
||||
//!
|
||||
//! The update pipeline swaps the archipelago binary but does not touch
|
||||
//! scripts, systemd units, or nginx configuration — those are installed
|
||||
//! once by the ISO builder. Without this module, changes to
|
||||
//! `container-doctor.sh`, the doctor service/timer, or the nginx config
|
||||
//! never reach boxes installed before the change.
|
||||
//!
|
||||
//! Two things are synced on startup:
|
||||
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
|
||||
//! 2. An nginx `location /api/app-catalog` proxy block — required for
|
||||
//! the App Store catalog proxy to actually reach the backend.
|
||||
//!
|
||||
//! Idempotent: no-ops on boxes that are already in sync. 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";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
|
||||
/// image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts 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),
|
||||
}
|
||||
match run_nginx().await {
|
||||
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"),
|
||||
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
|
||||
Err(e) => warn!("Nginx 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)
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
|
||||
/// it's missing. The original ISO shipped individual per-endpoint `location`
|
||||
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
|
||||
/// through to the SPA `index.html` and the frontend got HTML instead of
|
||||
/// JSON. We anchor the insert to the DWN comment that already sits right
|
||||
/// after the `/api/blob` block, so the new block lands in both the HTTP
|
||||
/// and HTTPS server blocks.
|
||||
///
|
||||
/// Validates via `nginx -t` before reloading. On failure the patch is
|
||||
/// rolled back from a backup written just before the write.
|
||||
async fn run_nginx() -> Result<bool> {
|
||||
// Skip on dev symlinks — we don't want to touch `/etc/nginx` on laptops.
|
||||
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)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !Path::new(NGINX_CONF_PATH).exists() {
|
||||
debug!(
|
||||
"{} missing — skipping nginx bootstrap",
|
||||
NGINX_CONF_PATH
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(NGINX_CONF_PATH)
|
||||
.await
|
||||
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
|
||||
if content.contains("location /api/app-catalog") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// The DWN comment sits at the same indent right after the `/api/blob`
|
||||
// block in both server blocks — a stable anchor that existed on every
|
||||
// ISO shipped to date. If it's absent (config got heavily customized),
|
||||
// we bail rather than guess where to splice.
|
||||
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
|
||||
if !content.contains(anchor) {
|
||||
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
|
||||
let patched = content.replace(anchor, &replacement);
|
||||
|
||||
// Write patched config via a user-owned tmp + sudo mv, after stashing
|
||||
// a backup so we can revert if `nginx -t` hates what we produced.
|
||||
let pid = std::process::id();
|
||||
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
|
||||
fs::write(&tmp, &patched)
|
||||
.await
|
||||
.with_context(|| format!("write {}", tmp))?;
|
||||
|
||||
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid);
|
||||
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
return Err(e.context("backup nginx conf"));
|
||||
}
|
||||
|
||||
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await;
|
||||
match mv {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
anyhow::bail!("sudo mv nginx conf exited with {}", s);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
return Err(e.context("mv tmp -> nginx conf"));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate.
|
||||
let test = host_sudo(&["nginx", "-t"]).await;
|
||||
let valid = matches!(&test, Ok(s) if s.success());
|
||||
if !valid {
|
||||
warn!("nginx -t failed after patch — reverting");
|
||||
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
|
||||
if let Err(e) = test {
|
||||
return Err(e.context("nginx -t"));
|
||||
}
|
||||
anyhow::bail!("nginx config invalid after patch — reverted");
|
||||
}
|
||||
|
||||
// Reload nginx so the new block takes effect immediately. Reload (not
|
||||
// restart) keeps in-flight connections alive.
|
||||
if let Err(e) = host_sudo(&["systemctl", "reload", "nginx"]).await {
|
||||
warn!("nginx reload failed (non-fatal): {:#}", e);
|
||||
}
|
||||
let _ = host_sudo(&["rm", "-f", &backup]).await;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -44,11 +44,6 @@ impl DockerPackageScanner {
|
||||
"nbxplorer",
|
||||
"mempool-db",
|
||||
"mempool-api",
|
||||
"penpot-postgres",
|
||||
"penpot-backend",
|
||||
"penpot-exporter",
|
||||
"penpot-valkey",
|
||||
"penpot-mailcatch",
|
||||
"immich_postgres",
|
||||
"immich_redis",
|
||||
"endurain-db",
|
||||
@@ -247,6 +242,7 @@ impl DockerPackageScanner {
|
||||
status: service_status,
|
||||
}),
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
};
|
||||
|
||||
packages.insert(app_id.clone(), package);
|
||||
@@ -296,9 +292,16 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
|
||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
let mut meta = match app_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata {
|
||||
"bitcoin-core" => AppMetadata {
|
||||
title: "Bitcoin Core".to_string(),
|
||||
description: "Reference Bitcoin node implementation".to_string(),
|
||||
icon: "/assets/img/app-icons/bitcoin-core.svg".to_string(),
|
||||
repo: "https://github.com/bitcoin/bitcoin".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"bitcoin" | "bitcoin-knots" => AppMetadata {
|
||||
title: "Bitcoin Knots".to_string(),
|
||||
description: "Full Bitcoin node implementation".to_string(),
|
||||
description: "Enhanced Bitcoin node implementation".to_string(),
|
||||
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
|
||||
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
|
||||
tier: "",
|
||||
@@ -408,13 +411,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/cryptpad/cryptpad".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"penpot" | "penpot-frontend" => AppMetadata {
|
||||
title: "Penpot".to_string(),
|
||||
description: "Open-source design and prototyping".to_string(),
|
||||
icon: "/assets/img/app-icons/penpot.webp".to_string(),
|
||||
repo: "https://github.com/penpot/penpot".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"nextcloud" => AppMetadata {
|
||||
title: "Nextcloud".to_string(),
|
||||
description: "Self-hosted cloud storage and file management".to_string(),
|
||||
@@ -492,13 +488,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/indeedhub/indeedhub".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"nostr-rs-relay" => AppMetadata {
|
||||
title: "Nostr Relay".to_string(),
|
||||
description: "Run your own Nostr relay for sovereign event storage".to_string(),
|
||||
icon: "/assets/img/app-icons/nostr-rs-relay.svg".to_string(),
|
||||
repo: "https://sr.ht/~gheartsfield/nostr-rs-relay/".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"dwn" => AppMetadata {
|
||||
title: "Decentralized Web Node".to_string(),
|
||||
description: "Store and sync personal data with DID-based access control".to_string(),
|
||||
|
||||
@@ -57,6 +57,13 @@ impl Default for RegistryConfig {
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
},
|
||||
Registry {
|
||||
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||
name: "Server 3 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -80,42 +87,6 @@ impl RegistryConfig {
|
||||
format!("{}/{}", registry.url, image_name)
|
||||
}
|
||||
|
||||
/// Generate fallback image URLs to try (excludes the original since it already failed).
|
||||
pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
// Rewrite for each active registry (skip if identical to original)
|
||||
for reg in self.active_registries() {
|
||||
let rewritten = self.rewrite_image(image, reg);
|
||||
if rewritten != image {
|
||||
candidates.push((rewritten, reg.tls_verify));
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Rewrite an image to use the highest-priority enabled registry, so
|
||||
/// the FIRST pull attempt honors the operator's primary choice instead
|
||||
/// of blindly using whatever registry the image URL was hardcoded to.
|
||||
/// Returns (rewritten_url, tls_verify) — or the original URL + default
|
||||
/// tls_verify=true if there's no primary (no enabled registries).
|
||||
pub fn rewrite_for_primary(&self, image: &str) -> (String, bool) {
|
||||
match self.active_registries().first() {
|
||||
Some(primary) => (self.rewrite_image(image, primary), primary.tls_verify),
|
||||
None => (image.to_string(), true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the registry config and rewrite an image to use the primary
|
||||
/// registry's URL. Convenience wrapper for callers that don't already
|
||||
/// have a `RegistryConfig` in hand.
|
||||
pub async fn primary_image_url(data_dir: &Path, image: &str) -> (String, bool) {
|
||||
match load_registries(data_dir).await {
|
||||
Ok(config) => config.rewrite_for_primary(image),
|
||||
Err(_) => (image.to_string(), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the image name from a full image reference.
|
||||
@@ -126,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() {
|
||||
@@ -135,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)
|
||||
}
|
||||
|
||||
@@ -155,80 +157,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try pulling an image from configured registries in priority order.
|
||||
/// If `already_tried` is Some, that URL is skipped (avoids retrying the
|
||||
/// primary when the caller already attempted it with progress streaming).
|
||||
/// Returns the image reference that succeeded.
|
||||
pub async fn pull_from_registries_with_skip(
|
||||
data_dir: &Path,
|
||||
image: &str,
|
||||
tmpdir: &str,
|
||||
already_tried: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let config = load_registries(data_dir).await?;
|
||||
let mut candidates = config.image_candidates(image);
|
||||
if let Some(skip) = already_tried {
|
||||
candidates.retain(|(url, _)| url != skip);
|
||||
}
|
||||
|
||||
for (candidate, tls_verify) in &candidates {
|
||||
debug!("Trying registry: {}", candidate);
|
||||
|
||||
let mut args = vec!["pull".to_string(), candidate.clone()];
|
||||
if !tls_verify {
|
||||
args.push("--tls-verify=false".to_string());
|
||||
}
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(&args)
|
||||
.env("TMPDIR", tmpdir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.ok();
|
||||
|
||||
let status = if let Some(ref mut c) = child {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(120), c.wait()).await {
|
||||
Ok(Ok(s)) => Some(s.success()),
|
||||
_ => {
|
||||
let _ = c.kill().await;
|
||||
let _ = c.wait().await;
|
||||
debug!("Fallback pull timed out: {}", candidate);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if status == Some(true) {
|
||||
// If we pulled from a non-original registry, tag it with the original name
|
||||
if candidate != image {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["tag", candidate, image])
|
||||
.status()
|
||||
.await;
|
||||
info!(
|
||||
"Pulled {} from fallback registry, tagged as {}",
|
||||
candidate, image
|
||||
);
|
||||
} else {
|
||||
info!("Pulled {} from primary registry", image);
|
||||
}
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
|
||||
debug!("Failed to pull from {}", candidate);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to pull {} from all {} configured registries",
|
||||
image,
|
||||
candidates.len()
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -259,39 +187,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_candidates() {
|
||||
let config = RegistryConfig::default();
|
||||
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
// Defaults: VPS (primary) + tx1138. tx1138 is filtered out because
|
||||
// it's identical to the original image URL, leaving one candidate.
|
||||
assert_eq!(candidates.len(), 1);
|
||||
// Primary-first — VPS rewrite leads the candidate list.
|
||||
assert_eq!(candidates[0].0, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_for_primary_uses_top_priority() {
|
||||
let config = RegistryConfig::default();
|
||||
let (url, tls) =
|
||||
config.rewrite_for_primary("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||
assert_eq!(url, "23.182.128.160:3000/lfg2025/lnd:v0.18.4-beta");
|
||||
assert!(!tls, "VPS primary is HTTP — tls_verify should be false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_registries_sorted() {
|
||||
let config = RegistryConfig::default();
|
||||
let active = config.active_registries();
|
||||
assert_eq!(active.len(), 2);
|
||||
assert_eq!(active.len(), 3);
|
||||
assert!(active[0].priority <= active[1].priority);
|
||||
assert!(active[1].priority <= active[2].priority);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = load_registries(tmp.path()).await.unwrap();
|
||||
assert_eq!(config.registries.len(), 2);
|
||||
assert_eq!(config.registries.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -307,6 +216,6 @@ mod tests {
|
||||
});
|
||||
save_registries(tmp.path(), &config).await.unwrap();
|
||||
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.registries.len(), 3);
|
||||
assert_eq!(loaded.registries.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,13 @@ pub struct PackageDataEntry {
|
||||
pub installed: Option<InstalledPackageDataEntry>,
|
||||
#[serde(rename = "install-progress")]
|
||||
pub install_progress: Option<InstallProgress>,
|
||||
/// Live label describing the current uninstall step ("Stopping
|
||||
/// containers (2/5)", "Removing data", …). Set by the uninstall
|
||||
/// pipeline so the UI can show real progress instead of a generic
|
||||
/// "Uninstalling…" spinner. Cleared after the package entry is
|
||||
/// removed.
|
||||
#[serde(rename = "uninstall-stage", skip_serializing_if = "Option::is_none", default)]
|
||||
pub uninstall_stage: Option<String>,
|
||||
/// Pinned image version from image-versions.sh when it differs from running version
|
||||
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
|
||||
pub available_update: Option<String>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -28,6 +28,7 @@ mod avatar;
|
||||
mod backup;
|
||||
mod bitcoin_rpc;
|
||||
mod blobs;
|
||||
mod bootstrap;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod container;
|
||||
@@ -85,6 +86,36 @@ async fn main() -> Result<()> {
|
||||
|
||||
info!("Starting Archipelago Bitcoin Node OS");
|
||||
|
||||
// Self-heal web-ui permissions. The OTA updater in <=v1.7.38 left
|
||||
// /opt/archipelago/web-ui as drwx------ (700) after the atomic
|
||||
// swap — nginx (www-data) then returned 500/403 on every request
|
||||
// until someone shelled in and chmod'd it. Check on every boot
|
||||
// and repair if needed so a node auto-recovers after the next
|
||||
// service restart that follows a broken OTA.
|
||||
tokio::spawn(async move {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let web_ui = std::path::Path::new("/opt/archipelago/web-ui");
|
||||
if let Ok(meta) = tokio::fs::metadata(web_ui).await {
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o005 != 0o005 {
|
||||
tracing::warn!(
|
||||
"web-ui perms {:o} not world-readable — self-healing",
|
||||
mode
|
||||
);
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"chmod",
|
||||
"-R",
|
||||
"u=rwX,go=rX",
|
||||
"/opt/archipelago/web-ui",
|
||||
])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load configuration
|
||||
let config = Config::load().await?;
|
||||
info!("📁 Data directory: {}", config.data_dir.display());
|
||||
@@ -165,12 +196,30 @@ async fn main() -> Result<()> {
|
||||
// on a 30s poll of fips0 — so a post-onboarding fips.install brings it
|
||||
// online without needing an archipelago restart.
|
||||
|
||||
// Post-OTA verification: if apply_update() wrote a pending-verify
|
||||
// marker right before the restart, probe the frontend now and auto-
|
||||
// rollback if it's broken. This is the guardrail that stops fleet-
|
||||
// wide breakage when an OTA lands a subtly-bad release (v1.7.38/39
|
||||
// tarball-perms → nginx 500 was the trigger). Runs concurrently
|
||||
// with normal startup — doesn't delay the server coming up.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
update::verify_pending_update(&data_dir).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn background update scheduler
|
||||
let update_data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
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 +278,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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,8 +68,29 @@ 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";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
/// consumed by verify_pending_update() in the NEW binary's startup path.
|
||||
/// If present, the new binary probes the frontend; if the probe fails,
|
||||
/// rollback_update() runs and the service restarts on the old binary.
|
||||
/// Closes the "OTA broke nginx fleet-wide with no auto-rollback" failure
|
||||
/// mode from 2026-04-22 (v1.7.38/39 tarball-perms bug).
|
||||
const PENDING_VERIFY_FILE: &str = "update-pending-verify.json";
|
||||
/// Probe timeout for the frontend health check (total time including
|
||||
/// retries). Generous: the new binary has to come fully up, health
|
||||
/// monitor settles, nginx has to re-read any snippet changes. 90s is
|
||||
/// comfortably longer than the slowest observed startup.
|
||||
const PENDING_VERIFY_WINDOW_SECS: u64 = 90;
|
||||
/// If the marker is older than this on read, treat it as stale and
|
||||
/// delete without probing. Guards against a node that somehow failed
|
||||
/// to run verification at all (e.g. crashed during startup) from
|
||||
/// spontaneously rolling back days later when the user reboots.
|
||||
const PENDING_VERIFY_MAX_AGE_SECS: i64 = 600;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct UpdateMirror {
|
||||
@@ -97,13 +118,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 +144,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<()> {
|
||||
@@ -239,6 +285,189 @@ impl Default for UpdateState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
/// consumed by verify_pending_update() in the NEW binary's startup path.
|
||||
/// See PENDING_VERIFY_FILE for the full rationale — this is the hook
|
||||
/// that turns "nginx 500 on every page after OTA" from an unrecoverable
|
||||
/// field incident into an automatic rollback.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingVerification {
|
||||
/// RFC3339 timestamp of the apply that wrote this marker.
|
||||
pub applied_at: String,
|
||||
/// Version we just applied (what the NEW binary should be running).
|
||||
pub new_version: String,
|
||||
/// Version the outgoing binary was running (what we roll back to).
|
||||
pub previous_version: String,
|
||||
/// Unix epoch seconds after which the probe should give up and
|
||||
/// trigger rollback. Prevents a probe from retrying forever if e.g.
|
||||
/// nginx is totally wedged.
|
||||
pub deadline_ts: i64,
|
||||
}
|
||||
|
||||
async fn write_pending_verification(
|
||||
data_dir: &Path,
|
||||
marker: &PendingVerification,
|
||||
) -> Result<()> {
|
||||
let path = data_dir.join(PENDING_VERIFY_FILE);
|
||||
let data = serde_json::to_string_pretty(marker)
|
||||
.context("serialize pending-verify marker")?;
|
||||
fs::write(&path, data)
|
||||
.await
|
||||
.with_context(|| format!("write pending-verify marker to {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_pending_verification(data_dir: &Path) -> Option<PendingVerification> {
|
||||
let path = data_dir.join(PENDING_VERIFY_FILE);
|
||||
let data = fs::read_to_string(&path).await.ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
async fn clear_pending_verification(data_dir: &Path) {
|
||||
let path = data_dir.join(PENDING_VERIFY_FILE);
|
||||
let _ = fs::remove_file(&path).await;
|
||||
}
|
||||
|
||||
/// Probe the local frontend through nginx. Returns Ok(()) on the first
|
||||
/// response that's 2xx or 3xx; errors on timeout / connection refused /
|
||||
/// any 4xx/5xx. `accept_self_signed` because nodes use a self-signed
|
||||
/// cert the reqwest default root-set doesn't trust.
|
||||
async fn probe_frontend_once() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.context("build probe client")?;
|
||||
// Prefer HTTPS since that's the failure mode we're catching (nginx
|
||||
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
|
||||
// the bug.
|
||||
let resp = client
|
||||
.get("https://127.0.0.1/")
|
||||
.send()
|
||||
.await
|
||||
.context("probe GET https://127.0.0.1/")?;
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.is_redirection() {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("frontend probe returned HTTP {}", status);
|
||||
}
|
||||
|
||||
/// Called from main.rs startup. If a pending-verification marker is
|
||||
/// present, probe the frontend; on failure, trigger rollback and
|
||||
/// restart the service so the OLD binary boots.
|
||||
///
|
||||
/// This is the "post-OTA auto-rollback" guardrail. If ANY problem in
|
||||
/// the new version takes down the PWA (bad tarball perms as in v1.7.38,
|
||||
/// a broken service worker, a missing asset, a backend panic on first
|
||||
/// boot), the node self-heals back to the previous working state
|
||||
/// without SSH intervention.
|
||||
pub async fn verify_pending_update(data_dir: &Path) {
|
||||
let marker = match read_pending_verification(data_dir).await {
|
||||
Some(m) => m,
|
||||
None => return, // No update pending; nothing to verify.
|
||||
};
|
||||
|
||||
// Guard against a marker left behind by some earlier crash path —
|
||||
// don't want a user who reboots days later to suddenly get
|
||||
// rolled back because the marker was never cleared.
|
||||
let applied_at = chrono::DateTime::parse_from_rfc3339(&marker.applied_at);
|
||||
if let Ok(ts) = applied_at {
|
||||
let age = chrono::Utc::now() - ts.with_timezone(&chrono::Utc);
|
||||
if age.num_seconds() > PENDING_VERIFY_MAX_AGE_SECS {
|
||||
tracing::warn!(
|
||||
age_secs = age.num_seconds(),
|
||||
"pending-verify marker is stale, clearing without probing"
|
||||
);
|
||||
clear_pending_verification(data_dir).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
new_version = %marker.new_version,
|
||||
previous_version = %marker.previous_version,
|
||||
"Post-OTA verification: probing frontend at https://127.0.0.1/"
|
||||
);
|
||||
|
||||
// Give the new service time to bind its listeners + nginx to
|
||||
// pick up any config changes. 15s matches what we observed on
|
||||
// .116 during the v1.7.40 rollout recovery.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
|
||||
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
|
||||
let mut attempt = 0u32;
|
||||
let mut last_err: Option<String> = None;
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
attempt += 1;
|
||||
match probe_frontend_once().await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
attempt,
|
||||
"Post-OTA verification succeeded — clearing marker"
|
||||
);
|
||||
clear_pending_verification(data_dir).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
tracing::warn!(attempt, error = %msg, "Post-OTA probe failed, retrying");
|
||||
last_err = Some(msg);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
|
||||
tracing::error!(
|
||||
attempts = attempt,
|
||||
window_secs = PENDING_VERIFY_WINDOW_SECS,
|
||||
last_error = last_err.as_deref().unwrap_or(""),
|
||||
new_version = %marker.new_version,
|
||||
previous_version = %marker.previous_version,
|
||||
"Post-OTA verification FAILED — rolling back"
|
||||
);
|
||||
|
||||
// Restore web-ui.bak on top of web-ui. update.rs keeps web-ui.bak
|
||||
// from the previous apply; moving it back is the frontend half of
|
||||
// the rollback. The binary half is handled by rollback_update().
|
||||
let web_ui_bak = Path::new("/opt/archipelago/web-ui.bak");
|
||||
let web_ui = "/opt/archipelago/web-ui";
|
||||
if web_ui_bak.exists() {
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let quarantine = format!("/opt/archipelago/web-ui.failed.{}", ts);
|
||||
let _ = host_sudo(&["mv", web_ui, &quarantine]).await;
|
||||
let _ = host_sudo(&["mv", web_ui_bak.to_str().unwrap_or(""), web_ui]).await;
|
||||
tracing::info!(quarantined = %quarantine, "Restored web-ui from web-ui.bak");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"web-ui.bak not present — frontend cannot be rolled back, only binary"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = rollback_update(data_dir).await {
|
||||
tracing::error!(error = %e, "rollback_update() failed during post-OTA verification");
|
||||
// Leave the marker in place so a future boot gets another shot.
|
||||
return;
|
||||
}
|
||||
|
||||
clear_pending_verification(data_dir).await;
|
||||
|
||||
// Record why we rolled back so the UI can show it on the next boot.
|
||||
if let Ok(mut state) = load_state(data_dir).await {
|
||||
state.current_version = marker.previous_version.clone();
|
||||
if let Err(e) = save_state(data_dir, &state).await {
|
||||
tracing::warn!(error = %e, "Failed to update state after rollback");
|
||||
}
|
||||
}
|
||||
|
||||
// Restart so the old binary takes over. --no-block because we're
|
||||
// the service; systemd can't wait for us to exit before starting
|
||||
// the old process.
|
||||
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
|
||||
}
|
||||
|
||||
pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
let path = data_dir.join(UPDATE_STATE_FILE);
|
||||
if !path.exists() {
|
||||
@@ -763,7 +992,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",
|
||||
@@ -885,6 +1114,21 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
])
|
||||
.await;
|
||||
|
||||
// Set world-readable perms so nginx (runs as www-data)
|
||||
// can stat + serve the files. Without this, the tar
|
||||
// extraction inherits the staging-dir's 700 mode and
|
||||
// nginx returns 403/500 for every request after the
|
||||
// swap — exactly what bit .116 on the v1.7.38 rollout.
|
||||
let _ = host_sudo(&["chmod", "755", &staging_new]).await;
|
||||
let _ = host_sudo(&[
|
||||
"find", &staging_new, "-type", "d", "-exec", "chmod", "755", "{}", "+",
|
||||
])
|
||||
.await;
|
||||
let _ = host_sudo(&[
|
||||
"find", &staging_new, "-type", "f", "-exec", "chmod", "644", "{}", "+",
|
||||
])
|
||||
.await;
|
||||
|
||||
// Preserve paths that are installed outside the Vue build
|
||||
// (baked in by the ISO or sibling installers) and so
|
||||
// aren't in the new tarball. Without this copy, every OTA
|
||||
@@ -941,15 +1185,42 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
// Update state
|
||||
let previous_version = {
|
||||
let state = load_state(data_dir).await?;
|
||||
state.current_version.clone()
|
||||
};
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if let Some(manifest) = &state.available_update {
|
||||
let new_version = if let Some(manifest) = &state.available_update {
|
||||
state.current_version = manifest.version.clone();
|
||||
}
|
||||
manifest.version.clone()
|
||||
} else {
|
||||
state.current_version.clone()
|
||||
};
|
||||
state.available_update = None;
|
||||
state.update_in_progress = false;
|
||||
state.rollback_available = true;
|
||||
save_state(data_dir, &state).await?;
|
||||
|
||||
// Write the post-OTA verification marker BEFORE we schedule the
|
||||
// restart. The new binary will read it on startup, probe the
|
||||
// frontend, and auto-rollback if nginx is serving 5xx. Covers the
|
||||
// class of failure where "apply succeeds, restart succeeds, but
|
||||
// the UI is dead" (v1.7.38/39 tarball-perms bug). Best-effort —
|
||||
// a failed marker write shouldn't abort the apply.
|
||||
let marker = PendingVerification {
|
||||
applied_at: chrono::Utc::now().to_rfc3339(),
|
||||
new_version,
|
||||
previous_version,
|
||||
deadline_ts: chrono::Utc::now().timestamp()
|
||||
+ PENDING_VERIFY_WINDOW_SECS as i64
|
||||
+ 60,
|
||||
};
|
||||
if let Err(e) = write_pending_verification(data_dir, &marker).await {
|
||||
tracing::warn!(error = %e, "Failed to write post-OTA verify marker — rollback disabled for this OTA");
|
||||
} else {
|
||||
info!("Post-OTA verify marker written; new binary will probe on boot");
|
||||
}
|
||||
|
||||
// Clean staging
|
||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||
|
||||
@@ -979,9 +1250,24 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
let backup_binary = backup_dir.join("archipelago");
|
||||
if backup_binary.exists() {
|
||||
fs::copy(&backup_binary, "/usr/local/bin/archipelago")
|
||||
// Use host_sudo + mv so we escape the archipelago service's
|
||||
// ProtectSystem=strict mount namespace. A plain fs::copy or
|
||||
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
|
||||
// which would silently orphan the rollback — exactly the
|
||||
// opposite of what auto-rollback is for. Pattern matches
|
||||
// apply_update()'s binary swap above.
|
||||
let backup_str = backup_binary.to_string_lossy().to_string();
|
||||
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
|
||||
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.context("Failed to restore backup binary")?;
|
||||
.context("Failed to restore backup binary via host_sudo")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"cp backup binary into /usr/local/bin failed (exit {:?})",
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
info!("Binary rolled back to previous version");
|
||||
}
|
||||
|
||||
@@ -1187,9 +1473,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]
|
||||
@@ -1404,4 +1691,45 @@ mod tests {
|
||||
assert_eq!(status.current_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(status.rollback_available);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_verification_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = PendingVerification {
|
||||
applied_at: chrono::Utc::now().to_rfc3339(),
|
||||
new_version: "1.7.41-alpha".into(),
|
||||
previous_version: "1.7.40-alpha".into(),
|
||||
deadline_ts: chrono::Utc::now().timestamp() + 150,
|
||||
};
|
||||
write_pending_verification(dir.path(), &marker).await.unwrap();
|
||||
let read = read_pending_verification(dir.path()).await.unwrap();
|
||||
assert_eq!(read.new_version, "1.7.41-alpha");
|
||||
assert_eq!(read.previous_version, "1.7.40-alpha");
|
||||
clear_pending_verification(dir.path()).await;
|
||||
assert!(read_pending_verification(dir.path()).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_verification_absent_is_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(read_pending_verification(dir.path()).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_pending_update_noop_without_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// No marker written -- must return quickly without doing anything
|
||||
// risky (network probes, rollback calls). We're just asserting
|
||||
// it doesn't panic or hang.
|
||||
verify_pending_update(dir.path()).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pending_verify_constants_are_sensible() {
|
||||
// Window must be generous enough for nginx + backend startup,
|
||||
// but less than the stale-marker threshold so a normal cycle
|
||||
// can complete without the marker being considered stale.
|
||||
assert!(PENDING_VERIFY_WINDOW_SECS < PENDING_VERIFY_MAX_AGE_SECS as u64);
|
||||
assert!(PENDING_VERIFY_WINDOW_SECS >= 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
COPY assets/ /usr/share/nginx/html/assets/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user namespaces
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 976 KiB |
@@ -6,7 +6,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>Bitcoin Knots - Archipelago</title>
|
||||
<title id="pageTitle">Bitcoin Node - Archipelago</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
* {
|
||||
@@ -336,9 +336,10 @@
|
||||
<!-- Logo - Top Left -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="logo-gradient-border">
|
||||
<img
|
||||
<img
|
||||
id="implLogo"
|
||||
src="/assets/img/app-icons/bitcoin-knots.webp"
|
||||
alt="Bitcoin Knots"
|
||||
alt="Bitcoin Node"
|
||||
class="w-16 h-16"
|
||||
style="object-fit: contain;"
|
||||
onerror="this.style.display='none'"
|
||||
@@ -348,8 +349,8 @@
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Bitcoin Knots</h1>
|
||||
<p class="text-white/70">Enhanced Bitcoin node implementation</p>
|
||||
<h1 id="implName" class="text-3xl font-bold text-white mb-2">Bitcoin Node</h1>
|
||||
<p id="implTagline" class="text-white/70">Detecting implementation…</p>
|
||||
</div>
|
||||
|
||||
<!-- Node Status Info - Compact on Desktop -->
|
||||
@@ -385,8 +386,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick="openSettings()"
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1.6 3 4 3h8c2.4 0 4-1 4-3V7M4 7c0-2 1.6-3 4-3h8c2.4 0 4 1 4 3M4 7h16M9 11h6M9 15h6" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-xs text-white/60">Storage</p>
|
||||
<p class="text-sm font-medium text-white" id="storageMode">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick="openSettings()"
|
||||
class="px-4 py-3 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Settings
|
||||
@@ -556,19 +567,23 @@
|
||||
<div class="space-y-3">
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="font-semibold text-white mb-1">Network Mode</div>
|
||||
<div class="text-white/70 text-sm">Regtest (Development)</div>
|
||||
<div class="text-white/70 text-sm" id="settingsNetworkMode">Loading…</div>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="font-semibold text-white mb-1">Storage Mode</div>
|
||||
<div class="text-white/70 text-sm" id="settingsStorageMode">Loading…</div>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="font-semibold text-white mb-1">Transaction Index</div>
|
||||
<div class="text-white/70 text-sm">Enabled (txindex=1)</div>
|
||||
<div class="text-white/70 text-sm" id="settingsTxIndex">Loading…</div>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="font-semibold text-white mb-1">ZMQ Publishing</div>
|
||||
<div class="text-white/70 text-sm">Block & TX notifications enabled</div>
|
||||
<div class="text-white/70 text-sm" id="settingsZmq">Loading…</div>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="font-semibold text-white mb-1">RPC Access</div>
|
||||
<div class="text-white/70 text-sm">Enabled on 0.0.0.0:18443</div>
|
||||
<div class="text-white/70 text-sm" id="settingsRpc">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -630,6 +645,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation branding — detected from getnetworkinfo.subversion.
|
||||
// Bitcoin Knots identifies as "/Satoshi:<ver>/Knots:<date>/", Bitcoin Core as "/Satoshi:<ver>/".
|
||||
let brandingApplied = false;
|
||||
function applyImplBranding(subversion) {
|
||||
if (brandingApplied) return;
|
||||
if (!subversion) return;
|
||||
const isKnots = /Knots/i.test(subversion);
|
||||
const name = isKnots ? 'Bitcoin Knots' : 'Bitcoin Core';
|
||||
const tagline = isKnots
|
||||
? 'Enhanced Bitcoin node implementation'
|
||||
: 'Reference Bitcoin node implementation';
|
||||
const icon = isKnots
|
||||
? '/assets/img/app-icons/bitcoin-knots.webp'
|
||||
: '/assets/img/app-icons/bitcoin-core.svg';
|
||||
const pageTitle = document.getElementById('pageTitle');
|
||||
const implName = document.getElementById('implName');
|
||||
const implTagline = document.getElementById('implTagline');
|
||||
const implLogo = document.getElementById('implLogo');
|
||||
if (pageTitle) pageTitle.textContent = `${name} - Archipelago`;
|
||||
if (implName) implName.textContent = name;
|
||||
if (implTagline) implTagline.textContent = tagline;
|
||||
if (implLogo) { implLogo.src = icon; implLogo.alt = name; }
|
||||
brandingApplied = true;
|
||||
}
|
||||
|
||||
// Track last block count for animations
|
||||
let lastBlockCount = 0;
|
||||
|
||||
@@ -648,7 +688,9 @@
|
||||
}
|
||||
|
||||
const networkInfo = await callRPC('getnetworkinfo');
|
||||
|
||||
|
||||
applyImplBranding(networkInfo && networkInfo.subversion);
|
||||
|
||||
// Update network mode
|
||||
const chain = blockchainInfo.chain || 'unknown';
|
||||
const networkType = document.getElementById('networkType');
|
||||
@@ -666,6 +708,70 @@
|
||||
|
||||
if (networkType) networkType.textContent = networkShort;
|
||||
|
||||
// Mirror to Settings modal — Network Mode
|
||||
const settingsNetworkMode = document.getElementById('settingsNetworkMode');
|
||||
if (settingsNetworkMode) {
|
||||
const labels = { main: 'Mainnet', test: 'Testnet', signet: 'Signet', regtest: 'Regtest (Development)' };
|
||||
settingsNetworkMode.textContent = labels[chain] || networkShort;
|
||||
}
|
||||
|
||||
// Update storage mode (pruned vs full archive)
|
||||
const storageMode = document.getElementById('storageMode');
|
||||
if (storageMode) {
|
||||
const sizeGb = blockchainInfo.size_on_disk
|
||||
? (blockchainInfo.size_on_disk / 1e9).toFixed(1) + ' GB'
|
||||
: null;
|
||||
if (blockchainInfo.pruned) {
|
||||
storageMode.textContent = sizeGb ? `Pruned · ${sizeGb}` : 'Pruned';
|
||||
storageMode.className = 'text-sm font-medium text-amber-300';
|
||||
} else {
|
||||
storageMode.textContent = sizeGb ? `Full Archive · ${sizeGb}` : 'Full Archive';
|
||||
storageMode.className = 'text-sm font-medium text-emerald-300';
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror to Settings modal — Storage Mode
|
||||
const settingsStorageMode = document.getElementById('settingsStorageMode');
|
||||
if (settingsStorageMode) {
|
||||
if (blockchainInfo.pruned) {
|
||||
const heightNote = blockchainInfo.prune_height != null
|
||||
? ` (keeping from block ${blockchainInfo.prune_height.toLocaleString()})` : '';
|
||||
settingsStorageMode.textContent = `Pruned${heightNote}`;
|
||||
} else {
|
||||
settingsStorageMode.textContent = 'Full archive (no pruning)';
|
||||
}
|
||||
}
|
||||
|
||||
// Populate Settings — Transaction Index, ZMQ, RPC (fire-and-forget)
|
||||
(async () => {
|
||||
const txIndexEl = document.getElementById('settingsTxIndex');
|
||||
if (txIndexEl) {
|
||||
const idx = await callRPC('getindexinfo');
|
||||
if (idx && typeof idx === 'object') {
|
||||
const names = Object.keys(idx);
|
||||
txIndexEl.textContent = names.length
|
||||
? `Enabled: ${names.join(', ')}`
|
||||
: 'Disabled';
|
||||
} else {
|
||||
txIndexEl.textContent = 'Disabled';
|
||||
}
|
||||
}
|
||||
const zmqEl = document.getElementById('settingsZmq');
|
||||
if (zmqEl) {
|
||||
const zmq = await callRPC('getzmqnotifications');
|
||||
if (Array.isArray(zmq) && zmq.length) {
|
||||
zmqEl.textContent = zmq.map(z => `${z.type}@${z.address}`).join('; ');
|
||||
} else {
|
||||
zmqEl.textContent = 'Not enabled';
|
||||
}
|
||||
}
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl && networkInfo) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
rpcEl.textContent = `Reachable on port ${port}`;
|
||||
}
|
||||
})();
|
||||
|
||||
// Update sync status
|
||||
const blocks = blockchainInfo.blocks || 0;
|
||||
const headers = blockchainInfo.headers || 0;
|
||||
@@ -779,7 +885,9 @@
|
||||
const peerInfo = await callRPC('getpeerinfo');
|
||||
|
||||
if (networkInfo && blockchainInfo) {
|
||||
logsContent.textContent = `Bitcoin Knots version ${networkInfo.subversion || 'unknown'}
|
||||
applyImplBranding(networkInfo.subversion);
|
||||
const implLabel = /Knots/i.test(networkInfo.subversion || '') ? 'Bitcoin Knots' : 'Bitcoin Core';
|
||||
logsContent.textContent = `${implLabel} version ${networkInfo.subversion || 'unknown'}
|
||||
Network: ${blockchainInfo.chain}
|
||||
Blocks: ${blockchainInfo.blocks}
|
||||
Headers: ${blockchainInfo.headers}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 8202
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,236 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<title>FIPS - Archipelago</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; color: white; overflow-x: hidden; }
|
||||
.bg-layer { position: fixed; inset: 0; z-index: -10; background: linear-gradient(135deg, rgba(5,20,15,0.95) 0%, rgba(10,30,25,0.98) 50%, rgba(5,15,20,0.95) 100%); }
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: -5; }
|
||||
.glass-card { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.12); transform: translateZ(0); isolation: isolate; }
|
||||
.info-card { background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 12px; border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.container { max-width: 56rem; margin: 0 auto; padding: 1.5rem; }
|
||||
.flex { display: flex; } .flex-col { flex-direction: column; } .items-center { align-items: center; }
|
||||
.gap-3 { gap: 0.75rem; } .gap-4 { gap: 1rem; } .flex-1 { flex: 1; } .flex-shrink-0 { flex-shrink: 0; }
|
||||
.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; }
|
||||
.p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; }
|
||||
.grid { display: grid; } .grid-cols-2 { grid-template-columns: repeat(2, 1fr); } .grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.text-xs { font-size: 0.75rem; } .text-sm { font-size: 0.875rem; } .text-lg { font-size: 1.125rem; }
|
||||
.text-xl { font-size: 1.25rem; } .text-2xl { font-size: 1.5rem; }
|
||||
.font-bold { font-weight: 700; } .font-semibold { font-weight: 600; } .font-medium { font-weight: 500; } .font-mono { font-family: monospace; }
|
||||
.text-white-70 { color: rgba(255,255,255,0.7); } .text-white-60 { color: rgba(255,255,255,0.6); } .text-white-50 { color: rgba(255,255,255,0.5); }
|
||||
.text-emerald { color: #34d399; } .text-green { color: #4ade80; } .text-yellow { color: #fbbf24; } .text-red { color: #f87171; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.status-dot { width: 0.75rem; height: 0.75rem; border-radius: 9999px; }
|
||||
.bg-green { background: #4ade80; } .bg-yellow { background: #fbbf24; } .bg-red { background: #f87171; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
||||
.icon-box { width: 3.5rem; height: 3.5rem; border-radius: 0.75rem; background: rgba(52, 211, 153, 0.15); display: flex; align-items: center; justify-content: center; }
|
||||
.step { display: flex; gap: 1rem; align-items: flex-start; }
|
||||
.step-num { width: 2rem; height: 2rem; border-radius: 50%; background: rgba(52, 211, 153, 0.2); border: 1px solid rgba(52, 211, 153, 0.4); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; font-weight: 700; color: #34d399; flex-shrink: 0; }
|
||||
.copy-btn { padding: 0.5rem 0.625rem; background: none; border: none; border-left: 1px solid rgba(255,255,255,0.1); cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.2s ease; display: flex; align-items: center; }
|
||||
.copy-btn:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
.copy-btn.copied { color: #4ade80; }
|
||||
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; overflow: hidden; }
|
||||
.field-value { flex: 1; padding: 0.625rem 0.875rem; font-family: monospace; font-size: 0.8125rem; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.4); margin-bottom: 0.375rem; }
|
||||
.feature-icon { width: 2.5rem; height: 2.5rem; border-radius: 0.5rem; background: rgba(52, 211, 153, 0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
@media (max-width: 640px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-layer"></div>
|
||||
<div class="overlay"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="icon-box flex-shrink-0">
|
||||
<svg style="width:1.75rem;height:1.75rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold">FIPS</h1>
|
||||
<span class="text-xs text-white-50">v0.1.0</span>
|
||||
</div>
|
||||
<p class="text-white-60 text-sm">Free Internetworking Peering System</p>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div id="statusDot" class="status-dot bg-yellow animate-pulse"></div>
|
||||
<div>
|
||||
<p class="text-xs text-white-50">Status</p>
|
||||
<p class="text-sm font-medium" id="statusText">Checking...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What It Does -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">What is FIPS?</h2>
|
||||
<p class="text-white-70 text-sm mb-4" style="line-height:1.6">
|
||||
FIPS is a <strong style="color:white">self-organizing encrypted mesh network</strong>. Each node gets a
|
||||
<strong style="color:white">secp256k1 keypair</strong> (same as Nostr/Bitcoin) that serves as its identity.
|
||||
Nodes discover each other, negotiate encryption using the <strong style="color:white">Noise protocol</strong>,
|
||||
and route traffic without any central authority. A virtual network interface (<code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips0</code>)
|
||||
lets unmodified applications — SSH, web browsers, anything — communicate transparently over the mesh.
|
||||
Think of it as <strong style="color:white">a new internet layer, built on cryptographic identity</strong>.
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div class="feature-icon">
|
||||
<svg style="width:1.25rem;height:1.25rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">Zero Config</p>
|
||||
<p class="text-xs text-white-50">Self-organizing mesh</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div class="feature-icon">
|
||||
<svg style="width:1.25rem;height:1.25rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">End-to-End Encrypted</p>
|
||||
<p class="text-xs text-white-50">Noise IK + XK protocols</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div class="feature-icon">
|
||||
<svg style="width:1.25rem;height:1.25rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">Multi-Transport</p>
|
||||
<p class="text-xs text-white-50">UDP, TCP, Tor, BLE</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Identity -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">Node Identity</h2>
|
||||
<p class="text-white-60 text-sm mb-4">Your node's Nostr public key doubles as its FIPS mesh address. Share with peers to connect.</p>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<div class="field-label">Nostr Public Key (npub)</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value" id="npub">Loading...</span>
|
||||
<button class="copy-btn" onclick="copyField('npub', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">Mesh Ports</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value">UDP 2121 / TCP 8443</span>
|
||||
<button class="copy-btn" onclick="copyText('2121', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How to Use -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">How to Use</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold mb-2">Install FIPS on your other devices</p>
|
||||
<p class="text-xs text-white-60" style="line-height:1.5">Download <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips</code> from <a href="https://github.com/jmcorgan/fips" style="color:#34d399;text-decoration:underline" target="_blank">GitHub</a>. Build with <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">cargo build --release</code> (requires Rust 1.85+).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold mb-2">Configure peers in fips.yaml</p>
|
||||
<p class="text-xs text-white-60" style="line-height:1.5">Edit <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">/etc/fips/fips.yaml</code> on each device. Add your Archipelago node's IP and port as a peer. The node's npub above is its identity on the mesh.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold mb-2">Start the daemon and connect</p>
|
||||
<p class="text-xs text-white-60" style="line-height:1.5">Run <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips --config /etc/fips/fips.yaml</code>. A <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips0</code> virtual interface appears. Use <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fipsctl show peers</code> to see connected nodes. You can now SSH, browse, or run any IP app over the encrypted mesh using <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">.fips</code> DNS names.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Logs -->
|
||||
<div class="glass-card p-5">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">Container Logs</h2>
|
||||
<div id="logs" style="background:rgba(0,0,0,0.4);border-radius:0.5rem;padding:0.75rem;font-family:monospace;font-size:0.75rem;color:rgba(255,255,255,0.6);max-height:200px;overflow-y:auto;line-height:1.6">
|
||||
Fetching logs...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var COPY_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>';
|
||||
var CHECK_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
|
||||
|
||||
function flashCopied(btn) { btn.classList.add('copied'); var o = btn.innerHTML; btn.innerHTML = CHECK_SVG; setTimeout(function() { btn.classList.remove('copied'); btn.innerHTML = o; }, 1500); }
|
||||
function copyField(id, btn) { var t = document.getElementById(id).textContent.trim(); if (!t || t === 'Loading...') return; navigator.clipboard.writeText(t).then(function() { flashCopied(btn); }); }
|
||||
function copyText(text, btn) { navigator.clipboard.writeText(text).then(function() { flashCopied(btn); }); }
|
||||
|
||||
async function fetchNodeIdentity() {
|
||||
try {
|
||||
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'node.nostr-pubkey', params: {} }) });
|
||||
var data = await resp.json();
|
||||
if (data.result && data.result.npub) {
|
||||
document.getElementById('npub').textContent = data.result.npub;
|
||||
} else if (data.result && data.result.pubkey) {
|
||||
document.getElementById('npub').textContent = data.result.pubkey;
|
||||
}
|
||||
} catch(e) { document.getElementById('npub').textContent = 'Unavailable'; }
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'package.info', params: { id: 'fips' } }) });
|
||||
var data = await resp.json();
|
||||
var dot = document.getElementById('statusDot');
|
||||
var txt = document.getElementById('statusText');
|
||||
if (data.result && data.result.state === 'running') {
|
||||
dot.className = 'status-dot bg-green'; txt.textContent = 'Running';
|
||||
} else if (data.result && data.result.state === 'stopped') {
|
||||
dot.className = 'status-dot bg-red'; txt.textContent = 'Stopped';
|
||||
} else {
|
||||
dot.className = 'status-dot bg-yellow animate-pulse'; txt.textContent = data.result ? data.result.state : 'Unknown';
|
||||
}
|
||||
} catch(e) { /* keep checking */ }
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
var resp = await fetch('/api/container/logs?app_id=fips&lines=30');
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
var logs = data.logs || data.stdout || '';
|
||||
if (typeof logs === 'object') logs = JSON.stringify(logs);
|
||||
document.getElementById('logs').textContent = logs || 'No logs available yet.';
|
||||
var el = document.getElementById('logs');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
} catch(e) { document.getElementById('logs').textContent = 'Waiting for container...'; }
|
||||
}
|
||||
|
||||
fetchNodeIdentity();
|
||||
fetchStatus();
|
||||
fetchLogs();
|
||||
setInterval(fetchStatus, 10000);
|
||||
setInterval(fetchLogs, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +0,0 @@
|
||||
server {
|
||||
listen 8202;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 8201
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,232 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<title>Nostr VPN - Archipelago</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; color: white; overflow-x: hidden; }
|
||||
.bg-layer { position: fixed; inset: 0; z-index: -10; background: linear-gradient(135deg, rgba(10,5,30,0.95) 0%, rgba(20,10,50,0.98) 50%, rgba(5,15,35,0.95) 100%); }
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: -5; }
|
||||
.glass-card { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.12); transform: translateZ(0); isolation: isolate; }
|
||||
.info-card { background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 12px; border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.container { max-width: 56rem; margin: 0 auto; padding: 1.5rem; }
|
||||
.flex { display: flex; } .flex-col { flex-direction: column; } .items-center { align-items: center; }
|
||||
.gap-3 { gap: 0.75rem; } .gap-4 { gap: 1rem; } .flex-1 { flex: 1; } .flex-shrink-0 { flex-shrink: 0; }
|
||||
.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; }
|
||||
.p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; }
|
||||
.grid { display: grid; } .grid-cols-2 { grid-template-columns: repeat(2, 1fr); } .grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.text-xs { font-size: 0.75rem; } .text-sm { font-size: 0.875rem; } .text-lg { font-size: 1.125rem; }
|
||||
.text-xl { font-size: 1.25rem; } .text-2xl { font-size: 1.5rem; }
|
||||
.font-bold { font-weight: 700; } .font-semibold { font-weight: 600; } .font-medium { font-weight: 500; } .font-mono { font-family: monospace; }
|
||||
.text-white-70 { color: rgba(255,255,255,0.7); } .text-white-60 { color: rgba(255,255,255,0.6); } .text-white-50 { color: rgba(255,255,255,0.5); }
|
||||
.text-purple { color: #a78bfa; } .text-green { color: #4ade80; } .text-yellow { color: #fbbf24; } .text-red { color: #f87171; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.status-dot { width: 0.75rem; height: 0.75rem; border-radius: 9999px; }
|
||||
.bg-green { background: #4ade80; } .bg-yellow { background: #fbbf24; } .bg-red { background: #f87171; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
||||
.icon-box { width: 3.5rem; height: 3.5rem; border-radius: 0.75rem; background: rgba(167, 139, 250, 0.15); display: flex; align-items: center; justify-content: center; }
|
||||
.step { display: flex; gap: 1rem; align-items: flex-start; }
|
||||
.step-num { width: 2rem; height: 2rem; border-radius: 50%; background: rgba(167, 139, 250, 0.2); border: 1px solid rgba(167, 139, 250, 0.4); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; font-weight: 700; color: #a78bfa; flex-shrink: 0; }
|
||||
.copy-btn { padding: 0.5rem 0.625rem; background: none; border: none; border-left: 1px solid rgba(255,255,255,0.1); cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.2s ease; display: flex; align-items: center; }
|
||||
.copy-btn:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
.copy-btn.copied { color: #4ade80; }
|
||||
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; overflow: hidden; }
|
||||
.field-value { flex: 1; padding: 0.625rem 0.875rem; font-family: monospace; font-size: 0.8125rem; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.4); margin-bottom: 0.375rem; }
|
||||
.feature-icon { width: 2.5rem; height: 2.5rem; border-radius: 0.5rem; background: rgba(167, 139, 250, 0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
@media (max-width: 640px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-layer"></div>
|
||||
<div class="overlay"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="icon-box flex-shrink-0">
|
||||
<svg style="width:1.75rem;height:1.75rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold">Nostr VPN</h1>
|
||||
<span class="text-xs text-white-50">v0.3.4</span>
|
||||
</div>
|
||||
<p class="text-white-60 text-sm">Decentralized mesh VPN with Nostr signaling</p>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div id="statusDot" class="status-dot bg-yellow animate-pulse"></div>
|
||||
<div>
|
||||
<p class="text-xs text-white-50">Status</p>
|
||||
<p class="text-sm font-medium" id="statusText">Checking...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What It Does -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">What is Nostr VPN?</h2>
|
||||
<p class="text-white-70 text-sm mb-4" style="line-height:1.6">
|
||||
Nostr VPN creates a <strong style="color:white">private mesh network</strong> between your devices using WireGuard tunnels.
|
||||
Unlike traditional VPNs, there is no central server. Peers discover each other and exchange encryption keys over
|
||||
<strong style="color:white">Nostr relays</strong>, making the network censorship-resistant and self-sovereign.
|
||||
Think of it as <strong style="color:white">Tailscale, but decentralized</strong> — your node's Nostr identity is your network identity.
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div class="feature-icon">
|
||||
<svg style="width:1.25rem;height:1.25rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">No Central Server</p>
|
||||
<p class="text-xs text-white-50">Fully peer-to-peer mesh</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div class="feature-icon">
|
||||
<svg style="width:1.25rem;height:1.25rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">WireGuard Tunnels</p>
|
||||
<p class="text-xs text-white-50">Fast, modern encryption</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div class="feature-icon">
|
||||
<svg style="width:1.25rem;height:1.25rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">NAT Traversal</p>
|
||||
<p class="text-xs text-white-50">Works behind firewalls</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Identity -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">Node Identity</h2>
|
||||
<p class="text-white-60 text-sm mb-4">Your node's Nostr public key is used as its network identity. Share it with peers to connect.</p>
|
||||
<div class="mb-4">
|
||||
<div class="field-label">Nostr Public Key (npub)</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value" id="npub">Loading...</span>
|
||||
<button class="copy-btn" onclick="copyField('npub', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">VPN Listen Port</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value">51820/udp</span>
|
||||
<button class="copy-btn" onclick="copyText('51820', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How to Use -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">How to Use</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold mb-2">Install the Nostr VPN client on your device</p>
|
||||
<p class="text-xs text-white-60" style="line-height:1.5">Download <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">nvpn</code> from <a href="https://github.com/mmalmi/nostr-vpn/releases" style="color:#a78bfa;text-decoration:underline" target="_blank">GitHub Releases</a> on your laptop, phone, or other devices you want to connect.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold mb-2">Create or join a network</p>
|
||||
<p class="text-xs text-white-60" style="line-height:1.5">Run <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">nvpn network create</code> on this node to create a new network, or join an existing one with an invite code. Each network gets a unique ID shared between members.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold mb-2">Connect your devices</p>
|
||||
<p class="text-xs text-white-60" style="line-height:1.5">Run <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">nvpn start --daemon --connect</code> on each device. Peers discover each other automatically over Nostr relays and establish direct WireGuard tunnels. Your devices are now privately connected.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Status -->
|
||||
<div class="glass-card p-5">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">Container Logs</h2>
|
||||
<div id="logs" style="background:rgba(0,0,0,0.4);border-radius:0.5rem;padding:0.75rem;font-family:monospace;font-size:0.75rem;color:rgba(255,255,255,0.6);max-height:200px;overflow-y:auto;line-height:1.6">
|
||||
Fetching logs...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var COPY_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>';
|
||||
var CHECK_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
|
||||
|
||||
function flashCopied(btn) { btn.classList.add('copied'); var o = btn.innerHTML; btn.innerHTML = CHECK_SVG; setTimeout(function() { btn.classList.remove('copied'); btn.innerHTML = o; }, 1500); }
|
||||
function copyField(id, btn) { var t = document.getElementById(id).textContent.trim(); if (!t || t === 'Loading...') return; navigator.clipboard.writeText(t).then(function() { flashCopied(btn); }); }
|
||||
function copyText(text, btn) { navigator.clipboard.writeText(text).then(function() { flashCopied(btn); }); }
|
||||
|
||||
async function fetchNodeIdentity() {
|
||||
try {
|
||||
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'node.nostr-pubkey', params: {} }) });
|
||||
var data = await resp.json();
|
||||
if (data.result && data.result.npub) {
|
||||
document.getElementById('npub').textContent = data.result.npub;
|
||||
} else if (data.result && data.result.pubkey) {
|
||||
document.getElementById('npub').textContent = data.result.pubkey;
|
||||
}
|
||||
} catch(e) { document.getElementById('npub').textContent = 'Unavailable'; }
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'package.info', params: { id: 'nostr-vpn' } }) });
|
||||
var data = await resp.json();
|
||||
var dot = document.getElementById('statusDot');
|
||||
var txt = document.getElementById('statusText');
|
||||
if (data.result && data.result.state === 'running') {
|
||||
dot.className = 'status-dot bg-green'; txt.textContent = 'Running';
|
||||
} else if (data.result && data.result.state === 'stopped') {
|
||||
dot.className = 'status-dot bg-red'; txt.textContent = 'Stopped';
|
||||
} else {
|
||||
dot.className = 'status-dot bg-yellow animate-pulse'; txt.textContent = data.result ? data.result.state : 'Unknown';
|
||||
}
|
||||
} catch(e) { /* keep checking */ }
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
var resp = await fetch('/api/container/logs?app_id=nostr-vpn&lines=30');
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
var logs = data.logs || data.stdout || '';
|
||||
if (typeof logs === 'object') logs = JSON.stringify(logs);
|
||||
document.getElementById('logs').textContent = logs || 'No logs available yet.';
|
||||
var el = document.getElementById('logs');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
} catch(e) { document.getElementById('logs').textContent = 'Waiting for container...'; }
|
||||
}
|
||||
|
||||
fetchNodeIdentity();
|
||||
fetchStatus();
|
||||
fetchLogs();
|
||||
setInterval(fetchStatus, 10000);
|
||||
setInterval(fetchLogs, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +0,0 @@
|
||||
server {
|
||||
listen 8201;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
# Bulletproof Containers for Beta
|
||||
|
||||
**Status**: plan agreed 2026-04-22, implementation started.
|
||||
**Target**: zero-manual-intervention container lifecycle for the beta launch. A user installs, uninstalls, reboots, updates, or loses power — every combination must leave the node in a known-good state without SSH.
|
||||
**Project memory**: `~/.claude/projects/-home-archipelago-Projects-archy/memory/project_reconcile_architecture.md`
|
||||
**Failure log**: `~/.claude/projects/-home-archipelago-Projects-archy/memory/feedback_container_lifecycle_failure_modes.md`
|
||||
|
||||
---
|
||||
|
||||
## Why we're doing this
|
||||
|
||||
The v1.7.38 and v1.7.39 rollouts on 2026-04-22 exposed a cluster of container-lifecycle failures that required manual SSH recovery on every affected node (.116, .198, .228, .253). If a user had been on those nodes, they'd have been stuck with "can't reach" or 500 errors and no path forward. We can't ship beta with this class of failure on the table.
|
||||
|
||||
The pattern under every failure: **the canonical source of truth had the right answer, but derived state drifted away from it and nothing noticed or fixed it.**
|
||||
|
||||
### The six failure modes
|
||||
|
||||
| # | Symptom | Root cause |
|
||||
|---|---|---|
|
||||
| FM1 | `archy-bitcoin-ui` + `archy-lnd-ui` disappeared from `podman ps -a` after a daemon restart | Archipelago owns container creation imperatively; no owner recreates companions after a crash mid-transition |
|
||||
| FM2 | ElectrumX "Daemon connection problem" | `bitcoin.conf`'s `rpcauth` drifted from `/var/lib/archipelago/secrets/bitcoin-rpc-password` — config written once at install, never re-derived |
|
||||
| FM3 | archipelago.service `status=226/NAMESPACE` crash-loop SIGKILL'd every child container | Containers were children of archipelago's cgroup; systemd teardown killed them. `KillMode=control-group` default |
|
||||
| FM4 | `host.containers.internal` inside containers resolved to LAN gateway (192.168.1.254) | Known podman bug on bridge networks pre-5.3 ([#22644](https://github.com/containers/podman/issues/22644)) |
|
||||
| FM5 | Nginx 500 fleet-wide after OTA | Tarball root dir was `drwx------` (700), extracted identically on every node. Fixed in v1.7.40 at build time; still need post-OTA auto-rollback |
|
||||
| FM6 | Rootless podman's `libpod/bolt_state.db` vanished → whole registry node unreachable | No detection of corrupt state; required manual `rm -rf /run/user/$UID/libpod` + `podman system renumber` |
|
||||
|
||||
---
|
||||
|
||||
## Architecture decision
|
||||
|
||||
**Adopt balena-style, level-triggered, desired-state reconciler built on Quadlet + sdnotify.**
|
||||
|
||||
This is the one architecture that would have prevented all six failures, because each one is "reality drifted from the intended config and nothing noticed" — the exact problem reconcilers are designed for.
|
||||
|
||||
### Why not the alternatives
|
||||
|
||||
- **Keep imperative + patch per-failure** — we've been doing this. Five releases in a day. Doesn't scale.
|
||||
- **Migrate to LXC (StartOS's path)** — 6-month project. Our investment in podman (`install.rs`, `docker_packages.rs`, `image_versions.rs`) is substantial. Quadlet gives us StartOS's isolation property without the migration.
|
||||
- **Ship k3s / MicroShift** — 400-800 MB RAM baseline on top of bitcoind/electrs. Overkill for a home node OS.
|
||||
- **Edge-triggered like Umbrel** — their `app.ts` has an explicit TODO admitting they don't handle failure events. We'd inherit the same bug class.
|
||||
|
||||
### The four patterns (from mature players)
|
||||
|
||||
1. **Desired-state-first, level-triggered reconcile.** balena-supervisor, Kubernetes operators, NixOS. A supervisor owns a manifest of *what should run*; on every tick it diffs against *what is running* and issues steps.
|
||||
2. **Every container is its own systemd unit, not a child of the daemon.** Red Hat's Quadlet pattern: a `.container` file is parsed by a systemd *generator* into a normal `.service`. The daemon can crash without taking any containers with it.
|
||||
3. **sdnotify readiness + HealthCmd + rollback.** Podman v3.4+ has real rollback: bad image fails health check, systemd considers service failed, Podman re-tags the previous image digest.
|
||||
4. **Credentials and config derived from canonical secrets on every apply.** Not trusted across upgrades; re-rendered idempotently from single source of truth.
|
||||
|
||||
### Fix-per-failure
|
||||
|
||||
| Failure | Fix |
|
||||
|---|---|
|
||||
| FM1 | Move companions to Quadlet `.container` files in `/etc/containers/systemd/`. systemd (not archipelago) owns them |
|
||||
| FM2 | `reconcile::derived::render_bitcoin_conf(secrets)` — pure function, runs every tick, atomic rewrite + HUP on drift |
|
||||
| FM3 | `KillMode=mixed` in archipelago.service + containers in their own `archipelago-apps.slice`. Quadlet units already live outside archipelago's cgroup |
|
||||
| FM4 | Ship `/etc/containers/containers.conf` with `host_containers_internal_ip = "10.89.0.1"` + `default_rootless_network_cmd = "pasta"`; also `--add-host=host.archipelago:10.89.0.1` in every unit |
|
||||
| FM5 | Post-OTA `curl -k https://127.0.0.1/` health probe in new binary startup. If non-200 within 90s, rollback to `web-ui.bak` + binary-backup |
|
||||
| FM6 | Startup probe: `podman info` with timeout. On "invalid internal status", clear `/run/user/$UID/{containers,libpod,podman}` + `podman system renumber` + reconcile tick rebuilds from Quadlet units |
|
||||
|
||||
---
|
||||
|
||||
## New code layout (lands in v1.7.48)
|
||||
|
||||
```
|
||||
core/archipelago/src/reconcile/
|
||||
mod.rs run_reconcile_loop, reconcile_once — called from main.rs
|
||||
desired.rs DesiredState built from packages.json + catalog + secrets
|
||||
current.rs snapshot via `systemctl list-units archy-*.service` + `podman ps -a --format json`
|
||||
diff.rs pure: reconcile(desired, current) -> Vec<Step> (unit-testable without podman)
|
||||
apply.rs step executor with timeouts, structured logs, backoff
|
||||
quadlet.rs write `.container` / `.volume` / `.network` units atomically
|
||||
derived.rs render_bitcoin_conf, render_containers_conf, render_nginx_app_routes
|
||||
backoff.rs restart-history tracking (moved from health_monitor.rs)
|
||||
```
|
||||
|
||||
### Step types (idempotent)
|
||||
|
||||
```rust
|
||||
enum Step {
|
||||
WriteQuadletUnit(path, content),
|
||||
WriteDerivedFile(path, content),
|
||||
WriteSecret(path, content),
|
||||
DaemonReload,
|
||||
EnsureStarted(unit),
|
||||
StopUnit(unit),
|
||||
RestartUnit(unit),
|
||||
PullImage(ref),
|
||||
}
|
||||
```
|
||||
|
||||
### Triggers
|
||||
|
||||
- 30s interval tick
|
||||
- install/uninstall RPC
|
||||
- update-applied event
|
||||
- explicit `/rpc/v1/reconcile.tick`
|
||||
- podman event stream (if available)
|
||||
|
||||
Level-triggered + idempotent — every call considers full desired vs current diff. Missed ticks/events are irrelevant.
|
||||
|
||||
### Edits to existing code
|
||||
|
||||
- **`src/main.rs`**: replace `tokio::spawn(crash_recovery::start_stopped_containers)` with `tokio::spawn(reconcile::run_reconcile_loop(state))`. Keep self-heal perms + PID-marker crash detection.
|
||||
- **`src/api/rpc/package/install.rs`**: stop calling `podman run` directly. Writes desired state + Quadlet unit + signals reconciler. Reconciler does pull + `systemctl start`.
|
||||
- **`src/api/rpc/package/runtime.rs`** + `lifecycle.rs` + `stacks.rs`: same pattern — mutate desired state, reconciler applies.
|
||||
- **`src/crash_recovery.rs`**: keep PID-marker + snapshot. Delete `start_stopped_containers` (reconciler handles cold boot). Keep `user-stopped.json` as `AppSpec.desired_state: Started | UserStopped | Uninstalled`.
|
||||
- **`src/health_monitor.rs`**: strip restart logic. Keep memory-leak detection; push unhealthy events as `Trigger::ContainerUnhealthy(name)`.
|
||||
- **`src/bitcoin_rpc.rs`**: add `pub fn derive_rpcauth_line(user, pass) -> String` (HMAC-SHA256 per Bitcoin Core's `rpcauth.py`).
|
||||
- **`src/update.rs`**: post-swap health probe + auto-rollback (v1.7.41).
|
||||
|
||||
---
|
||||
|
||||
## Shipping order
|
||||
|
||||
Each release is independently deployable. Not a big-bang rewrite.
|
||||
|
||||
### v1.7.41 — Post-OTA health probe + auto-rollback (closes FM5)
|
||||
- In `update.rs`: write `/var/lib/archipelago/update-pending-verify.json` just before service restart, with `applied_at`, `new_version`, `previous_version`, deadline.
|
||||
- In `main.rs` startup: read marker, spawn verification task. Wait 15s for full startup, then `curl -k https://127.0.0.1/` with retries up to 90s.
|
||||
- On 200: delete marker.
|
||||
- On non-200 after window: call `rollback_update(data_dir)` (already exists), restart service to boot the old binary.
|
||||
- Smallest diff, highest ROI.
|
||||
|
||||
### v1.7.42 — containers.conf + host.archipelago alias (closes FM4)
|
||||
- Idempotent write of `/etc/containers/containers.conf` on startup (archipelago compares hash, rewrites only on drift).
|
||||
- Add `--add-host=host.archipelago:10.89.0.1` to every generated container in `install.rs` / `docker_packages.rs`.
|
||||
- ElectrumX `DAEMON_URL` migrates from `host.containers.internal` → `host.archipelago`.
|
||||
|
||||
### v1.7.43 — `reconcile::derived` for bitcoin.conf / lnd.conf (closes FM2)
|
||||
- Pure function `render_bitcoin_conf(secrets) -> String`.
|
||||
- Tick every 30s: read secret, derive `rpcauth`, compare to on-disk, atomic rewrite (via `tempfile::NamedTempFile::persist`) + `podman exec ... kill -HUP 1` on drift.
|
||||
- Same pattern for `lnd.conf`.
|
||||
- First user of the eventual `reconcile::` module — ships the `derived.rs` piece early.
|
||||
|
||||
### v1.7.44 — Podman state self-heal on startup (closes FM6)
|
||||
- Startup probe: `podman info --format '{{.Host.OS}}'` with 10s timeout.
|
||||
- On "invalid internal status" or similar:
|
||||
- `systemctl --user stop podman.socket podman.service`
|
||||
- `rm -rf /run/user/$UID/{containers,libpod,podman}`
|
||||
- `podman system renumber`
|
||||
- Trigger reconcile tick (will rebuild containers from their source of truth)
|
||||
- Surface clear error on `/health` if recovery fails — don't silently serve 502.
|
||||
|
||||
### v1.7.45–47 — Quadlet migration per companion (closes FM1 + FM3)
|
||||
One companion per release so regressions have a narrow blame window:
|
||||
|
||||
- **v1.7.45**: `archy-bitcoin-ui` → Quadlet `.container` unit
|
||||
- **v1.7.46**: `archy-lnd-ui` → Quadlet
|
||||
- **v1.7.47**: `archy-electrs-ui` → Quadlet
|
||||
|
||||
Each:
|
||||
1. Write `.container` file to `/etc/containers/systemd/<name>.container`
|
||||
2. `systemctl daemon-reload`
|
||||
3. `systemctl enable --now <name>.service`
|
||||
4. Remove the `podman run` path from `install.rs` for that name
|
||||
5. Add Goss probe for the lifecycle test matrix
|
||||
|
||||
### v1.7.48+ — Full reconcile module
|
||||
- `core/archipelago/src/reconcile/` replaces imperative `install.rs` container management.
|
||||
- Main app containers (bitcoin-knots, bitcoin-core, lnd, electrumx, btcpay-server, mempool, fedimint) become Quadlet units.
|
||||
- `install.rs` shrinks to ~300 lines of "write desired state, poke reconciler."
|
||||
- Biggest diff, lands last.
|
||||
|
||||
---
|
||||
|
||||
## Test harness (parallel track)
|
||||
|
||||
### Stack
|
||||
|
||||
- **Outer runner**: `bats-core` — TAP-style bash testing, readable by anyone
|
||||
- **Verifier**: `goss` — YAML assertions on ports, processes, HTTP endpoints, files. Reused by CI + live probe
|
||||
- **Chaos layer**: Chaos Toolkit JSON experiments (steady-state-hypothesis → method → rollback → verify)
|
||||
- **VM layer**: `vmtest` (Go) for reboot-survival + ISO-boot tests, or raw QEMU+SSH
|
||||
- **Tor probe**: curl through archipelago's own tor SOCKS5 (`--socks5-hostname 127.0.0.1:9050`), 60-180s retry window
|
||||
- **Live probe**: small Rust agent on every fleet node, ships same Goss YAMLs to Prometheus. Neither Umbrel nor StartOS has this — real differentiator.
|
||||
- **Reproducibility**: btrfs subvolume snapshots primary (fast), QEMU qcow2 for ISO/kernel-level repro
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
tests/lifecycle/
|
||||
bats/
|
||||
_helpers.bash # install_app, wait_healthy, assert_no_orphans
|
||||
00_bootstrap.bats
|
||||
10_install.bats # per-app install
|
||||
20_ui_reachable.bats # direct port + HTTPS proxy + iframe
|
||||
30_tor_reachable.bats # .onion probe
|
||||
40_stop_start.bats
|
||||
50_restart.bats
|
||||
60_reboot.bats # vmtest-driven
|
||||
70_reinstall.bats # idempotence + data preservation
|
||||
80_uninstall.bats # leak check
|
||||
90_soak.bats # 2-6h hold, periodic probe
|
||||
goss/
|
||||
bitcoin-knots.yaml
|
||||
bitcoin-core.yaml
|
||||
lnd.yaml
|
||||
electrumx.yaml
|
||||
btcpay-server.yaml
|
||||
mempool.yaml
|
||||
fedimint.yaml
|
||||
chaos/
|
||||
kill9_archipelago_mid_install.json
|
||||
wipe_bolt_db.json
|
||||
kill9_bitcoind.json
|
||||
reboot_during_ota.json
|
||||
corrupt_bitcoin_conf.json
|
||||
systemctl_restart_mid_install.json
|
||||
fill_disk_99_percent.json
|
||||
kill_tor.json
|
||||
delete_nginx_snippet.json
|
||||
clock_jump_30min.json
|
||||
vm/
|
||||
iso_boot_smoke.go
|
||||
reboot_survival.go
|
||||
ci/
|
||||
vm_runner.sh
|
||||
collect_artifacts.sh
|
||||
probe/archy-probe/ # Rust bin, reuses goss YAMLs, ships to fleet
|
||||
Makefile # `make beta-matrix`, `make chaos`, `make soak`
|
||||
```
|
||||
|
||||
### Minimum beta matrix
|
||||
|
||||
7 apps × 9 lifecycle events × 10 chaos scenarios. Pass = every MUST-ship cell green on fresh rootless-podman single-node CI.
|
||||
|
||||
| Case \ App | knots | core | lnd | electrumx | btcpay | mempool | fedimint |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Fresh install | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| UI direct port | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| UI HTTPS proxy | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| UI iframe | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Tor .onion reachable | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
|
||||
| Stop → ports released | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Restart → integrations | — | — | ✓↔btc | ✓↔btc | ✓↔btc,lnd | ✓↔electrs | — |
|
||||
| Reboot survival | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Reinstall idempotent | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Uninstall no orphans | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| 6h soak | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
**Harness scaffold lands in v1.7.41.** First lifecycle tests blocking v1.7.45. Full matrix + chaos suite blocking beta tag.
|
||||
|
||||
### Chaos scenarios (10)
|
||||
|
||||
Ordered by likelihood × severity:
|
||||
|
||||
1. `kill -9 archipelagod` mid-install → systemd restart, in-flight install resumes or cleanly rolls back
|
||||
2. `rm bolt_state.db` while service stopped → restart regenerates, no data loss in named volumes
|
||||
3. `systemctl restart archipelago` mid-install → no orphans, no half-state
|
||||
4. Reboot mid-OTA → old version intact OR new version active, never half
|
||||
5. Corrupt `bitcoin.conf` → container restart-loops; UI surfaces banner; reconcile re-derives; other apps unaffected
|
||||
6. Fill `/var` to 99% → graceful degradation, disk-pressure report
|
||||
7. Revoke rootless-netns → self-heal within Tor descriptor window
|
||||
8. `pkill -9 tor` → supervisor restarts; onions reachable within 3–5 min
|
||||
9. Delete nginx conf snippet → reconciler rewrites or `archipelago doctor` flags drift
|
||||
10. Clock jump +30min → daemons survive; Tor recovers
|
||||
|
||||
---
|
||||
|
||||
## Decision log
|
||||
|
||||
| Decision | Answer | Rationale |
|
||||
|---|---|---|
|
||||
| Scope | 6+ incremental releases, not big-bang rewrite | Each closes one failure class, narrow blame window |
|
||||
| Quadlet migration | Yes | Isolation from daemon crashes, systemd-native recovery, free from Red Hat's production patterns. Minimum podman version becomes 4.4+ (fine for modern Debian) |
|
||||
| Live probe to Prometheus | Yes, part of beta | Genuine differentiator — neither Umbrel nor StartOS has this. Adds Grafana dep |
|
||||
| Test gating | Scaffold in v1.7.41, first tests blocking v1.7.45, full matrix blocking beta tag | Gradual rather than all-or-nothing |
|
||||
|
||||
---
|
||||
|
||||
## Key sources
|
||||
|
||||
### Architecture
|
||||
- Umbrel [app.ts](https://raw.githubusercontent.com/getumbrel/umbrel/master/packages/umbreld/source/modules/apps/app.ts) — edge-triggered, TODO on failure handling
|
||||
- StartOS [repo](https://github.com/Start9Labs/start-os), [v0.4 podman→LXC announce](https://community.start9.com/t/startos-v0-4-0-alpha-10-has-replaced-podman-new-commands-for-terminal/4062)
|
||||
- balena-supervisor [repo](https://github.com/balena-os/balena-supervisor), [Supervisor API](https://docs.balena.io/reference/supervisor/supervisor-api)
|
||||
- Quadlet: [Dan Walsh 2023 blog](https://www.redhat.com/en/blog/quadlet-podman), [podman-systemd.unit(5)](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)
|
||||
- Podman rollback: [auto-update blog](https://www.redhat.com/en/blog/podman-auto-updates-rollbacks), [podman-auto-update(1)](https://docs.podman.io/en/latest/markdown/podman-auto-update.1.html)
|
||||
- Kubernetes operator pattern: [Kubebuilder reconcile](https://deepwiki.com/kubernetes-sigs/kubebuilder/5.2-reconciliation-loop), [good practices](https://book.kubebuilder.io/reference/good-practices)
|
||||
- NixOS containers: [wiki](https://wiki.nixos.org/wiki/NixOS_Containers)
|
||||
|
||||
### Known bugs & references
|
||||
- `host.containers.internal` → LAN: [podman #22644](https://github.com/containers/podman/issues/22644), [#23782](https://github.com/containers/podman/issues/23782)
|
||||
- `bolt_state.db` recovery: [podman #17730](https://github.com/containers/podman/issues/17730), [staticdir mismatch #20872](https://github.com/containers/podman/issues/20872)
|
||||
- aardvark-dns flakiness: [#20396](https://github.com/containers/podman/issues/20396), [#22407](https://github.com/containers/podman/issues/22407)
|
||||
- systemd 226/NAMESPACE: [Arch forum](https://bbs.archlinux.org/viewtopic.php?id=156963), [systemd #29526](https://github.com/systemd/systemd/issues/29526)
|
||||
- [systemd CGROUP_DELEGATION](https://systemd.io/CGROUP_DELEGATION/), [systemd.kill(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html)
|
||||
|
||||
### Test harness prior art
|
||||
- Umbrel [ci.yml](https://github.com/getumbrel/umbrel/blob/master/.github/workflows/ci.yml) — Vitest + qemu matrix fan-out
|
||||
- [YunoHost package_check](https://github.com/YunoHost/package_check) — closest analog, scored per-app lifecycle harness on LXC
|
||||
- [bats-core](https://github.com/bats-core/bats-core)
|
||||
- [Goss](https://github.com/goss-org/goss), [dgoss](https://github.com/aelsabbahy/goss-docker)
|
||||
- [Chaos Toolkit](https://chaostoolkit.org/)
|
||||
- [vmtest (Go)](https://github.com/anatol/vmtest)
|
||||
|
||||
### Tor
|
||||
- [rend-spec-v3](https://github.com/torproject/torspec/blob/main/rend-spec-v3.txt) — descriptor lifetime + republish cadence
|
||||
- [stem](https://stem.torproject.org/) — Python Tor controller for `HS_DESC UPLOADED` waits
|
||||
|
||||
---
|
||||
|
||||
## To resume
|
||||
|
||||
1. Read project memory: `~/.claude/projects/-home-archipelago-Projects-archy/memory/project_reconcile_architecture.md`
|
||||
2. Read failure-mode memory: `~/.claude/projects/-home-archipelago-Projects-archy/memory/feedback_container_lifecycle_failure_modes.md`
|
||||
3. Check task list for current release (should start with v1.7.41)
|
||||
4. Current state on fleet as of 2026-04-22:
|
||||
- All 4 mirrors (tx1138, gitea-local, .160, .168) synced to v1.7.40-alpha
|
||||
- .116, .198, .228, .253 healed manually via `systemd-run chmod 755 /opt/archipelago/web-ui`
|
||||
- .228 still has stale `bitcoin.conf` rpcauth (regenerated during triage; will drift again until v1.7.43)
|
||||
- .228 UI companions (archy-bitcoin-ui, archy-lnd-ui) keep vanishing (Quadlet migration in v1.7.45+ fixes)
|
||||
- .160 Gitea required `podman system renumber` recovery (v1.7.44 automates this)
|
||||
5. Implementation is in progress on `main` branch — next edit is `core/archipelago/src/update.rs` for v1.7.41.
|
||||
@@ -241,6 +241,23 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# App Store catalog proxy — backend fetches from configured registries
|
||||
# so the browser doesn't hit CORS/CSP. Without this block nginx falls
|
||||
# through to the SPA index.html and the frontend gets HTML back instead
|
||||
# of JSON.
|
||||
location /api/app-catalog {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 15s;
|
||||
error_page 502 503 = @backend_unavailable;
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# DWN endpoints — peer access over Tor (no auth)
|
||||
location /dwn {
|
||||
limit_req zone=peer burst=20 nodelay;
|
||||
@@ -1029,6 +1046,23 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# App Store catalog proxy — backend fetches from configured registries
|
||||
# so the browser doesn't hit CORS/CSP. Without this block nginx falls
|
||||
# through to the SPA index.html and the frontend gets HTML back instead
|
||||
# of JSON.
|
||||
location /api/app-catalog {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 15s;
|
||||
error_page 502 503 = @backend_unavailable;
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# DWN endpoints — peer access over Tor (no auth)
|
||||
location /dwn {
|
||||
limit_req zone=peer burst=20 nodelay;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.6.0-alpha",
|
||||
"version": "1.7.38-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.6.0-alpha",
|
||||
"version": "1.7.38-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.6.0-alpha",
|
||||
"version": "1.7.41-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -14,6 +14,7 @@
|
||||
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
|
||||
"backend:mock": "node mock-backend.js",
|
||||
"backend:real": "cd ../core && cargo run --release",
|
||||
"prebuild": "cp ../app-catalog/catalog.json public/catalog.json",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:docker": "vite build",
|
||||
"build:production": "NODE_ENV=production vue-tsc -b && vite build --mode production",
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#10b981"/>
|
||||
<text x="32" y="38" text-anchor="middle" font-family="system-ui" font-size="16" font-weight="700" fill="white">FIPS</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 284 B |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<rect width="100" height="100" rx="20" fill="#7B2DBC"/>
|
||||
<circle cx="50" cy="40" r="12" stroke="white" stroke-width="3" fill="none"/>
|
||||
<circle cx="28" cy="68" r="8" stroke="white" stroke-width="2.5" fill="none"/>
|
||||
<circle cx="72" cy="68" r="8" stroke="white" stroke-width="2.5" fill="none"/>
|
||||
<line x1="42" y1="49" x2="33" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="58" y1="49" x2="67" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="40" r="4" fill="white"/>
|
||||
<circle cx="28" cy="68" r="3" fill="white"/>
|
||||
<circle cx="72" cy="68" r="3" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 718 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#6366f1"/>
|
||||
<text x="32" y="38" text-anchor="middle" font-family="system-ui" font-size="18" font-weight="700" fill="white">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#f59e0b"/>
|
||||
<text x="32" y="38" text-anchor="middle" font-family="system-ui" font-size="18" font-weight="700" fill="white">R</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 281 B |
@@ -1,415 +1,210 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-04-11T00:00:00Z",
|
||||
"registry": "23.182.128.160:3000/lfg2025",
|
||||
"version": 2,
|
||||
"updated": "2026-04-22T00:00:00Z",
|
||||
"registry": "git.tx1138.com/lfg2025",
|
||||
"featured": {
|
||||
"id": "indeedhub",
|
||||
"banner": "/assets/img/featured/indeedhub-banner.jpg",
|
||||
"headline": "Stream Sovereignty",
|
||||
"description": "Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more \u2014 streaming from your own node.",
|
||||
"description": "Bitcoin documentaries with Nostr identity.",
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"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.",
|
||||
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
|
||||
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
|
||||
"author": "Bitcoin Knots",
|
||||
"dockerImage": "bitcoin-knots:latest",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin",
|
||||
"category": "money",
|
||||
"tier": "core"
|
||||
"author": "Bitcoin Knots", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.",
|
||||
"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": "docker.io/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.",
|
||||
"icon": "/assets/img/app-icons/lnd.svg",
|
||||
"author": "Lightning Labs",
|
||||
"dockerImage": "lnd:v0.18.4-beta",
|
||||
"author": "Lightning Labs", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"category": "money",
|
||||
"tier": "core"
|
||||
"requires": ["bitcoin-knots"]
|
||||
},
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.",
|
||||
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"dockerImage": "btcpayserver:1.13.7",
|
||||
"author": "BTCPay Server Foundation", "category": "commerce", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"category": "commerce",
|
||||
"tier": "core"
|
||||
"requires": ["bitcoin-knots"]
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses.",
|
||||
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
|
||||
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"dockerImage": "mempool-frontend:v3.0.0",
|
||||
"author": "Mempool", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"category": "money",
|
||||
"tier": "core"
|
||||
"requires": ["bitcoin-knots", "electrumx"]
|
||||
},
|
||||
{
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups, privately.",
|
||||
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||
"icon": "/assets/img/app-icons/electrumx.webp",
|
||||
"author": "Luke Childs",
|
||||
"dockerImage": "electrumx:v1.18.0",
|
||||
"author": "Luke Childs", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"category": "money",
|
||||
"tier": "core"
|
||||
"requires": ["bitcoin-knots"]
|
||||
},
|
||||
{
|
||||
"id": "indeedhub",
|
||||
"title": "IndeeHub",
|
||||
"version": "1.0.0",
|
||||
"description": "Bitcoin documentary streaming with Nostr identity. Stream sovereignty content from your node.",
|
||||
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
|
||||
"description": "Bitcoin documentary streaming with Nostr identity.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub Team",
|
||||
"dockerImage": "indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub",
|
||||
"category": "community"
|
||||
"author": "IndeeHub", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
},
|
||||
{
|
||||
"id": "botfights",
|
||||
"title": "BotFights",
|
||||
"version": "1.0.0",
|
||||
"description": "Bot arena + 2-player arcade fighter with controller support.",
|
||||
"id": "botfights", "title": "BotFights", "version": "1.1.0",
|
||||
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
|
||||
"icon": "/assets/img/app-icons/botfights.svg",
|
||||
"author": "BotFights",
|
||||
"dockerImage": "botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net",
|
||||
"category": "community"
|
||||
"author": "BotFights", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net"
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.",
|
||||
"id": "gitea", "title": "Gitea", "version": "1.23",
|
||||
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"author": "Gitea",
|
||||
"author": "Gitea", "category": "development",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com",
|
||||
"category": "development"
|
||||
"repoUrl": "https://gitea.com"
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Web-based file manager. Browse, upload, and manage files on your server.",
|
||||
"id": "filebrowser", "title": "File Browser", "version": "2.27.0",
|
||||
"description": "Web-based file manager.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"dockerImage": "filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"category": "data",
|
||||
"tier": "core"
|
||||
"author": "File Browser", "category": "data", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser"
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.",
|
||||
"id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"dockerImage": "vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended"
|
||||
"author": "Vaultwarden", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"version": "2024.1.0",
|
||||
"description": "Privacy-respecting metasearch engine. Search the internet without being tracked.",
|
||||
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
|
||||
"description": "Privacy-respecting metasearch engine.",
|
||||
"icon": "/assets/img/app-icons/searxng.png",
|
||||
"author": "SearXNG",
|
||||
"dockerImage": "searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"category": "data",
|
||||
"tier": "recommended"
|
||||
"author": "SearXNG", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng"
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay",
|
||||
"version": "0.9.0",
|
||||
"description": "Your own Nostr relay. Store events locally, relay for friends, publish over Tor.",
|
||||
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
|
||||
"author": "scsiblade",
|
||||
"dockerImage": "nostr-rs-relay:0.9.0",
|
||||
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/",
|
||||
"category": "nostr"
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
"title": "Fedimint",
|
||||
"version": "0.10.0",
|
||||
"description": "Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.",
|
||||
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
|
||||
"description": "Federated Bitcoin mint with privacy through federated guardians.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"dockerImage": "fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||
"category": "money"
|
||||
"author": "Fedimint", "category": "money",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"title": "Ollama",
|
||||
"version": "0.5.4",
|
||||
"description": "Run AI models locally. Llama, Mistral, and more \u2014 on your hardware, completely private.",
|
||||
"id": "ollama", "title": "Ollama", "version": "0.5.4",
|
||||
"description": "Run AI models locally. Private and on your hardware.",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"author": "Ollama",
|
||||
"dockerImage": "ollama:latest",
|
||||
"repoUrl": "https://github.com/ollama/ollama",
|
||||
"category": "data"
|
||||
"author": "Ollama", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
|
||||
"repoUrl": "https://github.com/ollama/ollama"
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "28",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts \u2014 all on your hardware.",
|
||||
"id": "nextcloud", "title": "Nextcloud", "version": "28",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"dockerImage": "nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"category": "data"
|
||||
"author": "Nextcloud", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream your movies, music, and photos to any device.",
|
||||
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"dockerImage": "jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"category": "data"
|
||||
"author": "Jellyfin", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin"
|
||||
},
|
||||
{
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "1.90.0",
|
||||
"description": "High-performance photo and video backup. Mobile-first with ML features.",
|
||||
"id": "immich", "title": "Immich", "version": "1.90.0",
|
||||
"description": "High-performance photo and video backup with ML.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"dockerImage": "immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich",
|
||||
"category": "data"
|
||||
"author": "Immich", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
},
|
||||
{
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2024.1",
|
||||
"description": "Open-source home automation. Control smart home devices privately.",
|
||||
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
|
||||
"description": "Open-source home automation.",
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant",
|
||||
"dockerImage": "home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"category": "home"
|
||||
"author": "Home Assistant", "category": "home",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"description": "Analytics and monitoring platform. Dashboards for your node metrics.",
|
||||
"id": "grafana", "title": "Grafana", "version": "10.2.0",
|
||||
"description": "Analytics and monitoring dashboards.",
|
||||
"icon": "/assets/img/app-icons/grafana.png",
|
||||
"author": "Grafana Labs",
|
||||
"dockerImage": "grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"category": "data",
|
||||
"tier": "recommended"
|
||||
"author": "Grafana Labs", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"title": "Tailscale",
|
||||
"version": "1.78.0",
|
||||
"description": "Zero-config VPN. Secure remote access with WireGuard mesh networking.",
|
||||
"id": "tailscale", "title": "Tailscale", "version": "1.78.0",
|
||||
"description": "Zero-config VPN with WireGuard mesh networking.",
|
||||
"icon": "/assets/img/app-icons/tailscale.webp",
|
||||
"author": "Tailscale",
|
||||
"dockerImage": "tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale",
|
||||
"category": "networking",
|
||||
"tier": "recommended"
|
||||
"author": "Tailscale", "category": "networking", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale"
|
||||
},
|
||||
{
|
||||
"id": "penpot",
|
||||
"title": "Penpot",
|
||||
"version": "2.4",
|
||||
"description": "Open-source design platform. Self-hosted alternative to Figma.",
|
||||
"icon": "/assets/img/app-icons/penpot.webp",
|
||||
"author": "Penpot",
|
||||
"dockerImage": "penpot-frontend:2.4",
|
||||
"repoUrl": "https://github.com/penpot/penpot",
|
||||
"category": "data"
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition, privately.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism",
|
||||
"dockerImage": "photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||
"category": "data"
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.",
|
||||
"id": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0",
|
||||
"description": "Self-hosted uptime monitoring.",
|
||||
"icon": "/assets/img/app-icons/uptime-kuma.webp",
|
||||
"author": "Uptime Kuma",
|
||||
"dockerImage": "uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma",
|
||||
"category": "data",
|
||||
"tier": "recommended"
|
||||
"author": "Uptime Kuma", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||
},
|
||||
{
|
||||
"id": "nostr-vpn",
|
||||
"title": "Nostr VPN",
|
||||
"version": "0.3.7",
|
||||
"description": "Tailscale-style mesh VPN with Nostr control plane.",
|
||||
"icon": "/assets/img/app-icons/nostr-vpn.svg",
|
||||
"author": "Martti Malmi",
|
||||
"dockerImage": "nostr-vpn:v0.3.7",
|
||||
"repoUrl": "https://github.com/mmalmi/nostr-vpn",
|
||||
"category": "networking"
|
||||
},
|
||||
{
|
||||
"id": "fips",
|
||||
"title": "FIPS",
|
||||
"version": "0.1.0",
|
||||
"description": "Free Internetworking Peering System. Self-organizing encrypted mesh.",
|
||||
"icon": "/assets/img/app-icons/fips.svg",
|
||||
"author": "Jim Corgan",
|
||||
"dockerImage": "fips:v0.1.0",
|
||||
"repoUrl": "https://github.com/jmcorgan/fips",
|
||||
"category": "networking"
|
||||
},
|
||||
{
|
||||
"id": "routstr",
|
||||
"title": "Routstr",
|
||||
"version": "0.4.3",
|
||||
"description": "Decentralized AI inference proxy. Pay-per-request with Cashu ecash.",
|
||||
"icon": "/assets/img/app-icons/routstr.svg",
|
||||
"author": "Routstr",
|
||||
"dockerImage": "routstr:v0.4.3",
|
||||
"repoUrl": "https://github.com/routstr/routstr-core",
|
||||
"category": "community"
|
||||
},
|
||||
{
|
||||
"id": "dwn",
|
||||
"title": "Decentralized Web Node",
|
||||
"version": "0.4.0",
|
||||
"description": "Own your data with DID-based access control. Sync across devices.",
|
||||
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
|
||||
"description": "Own your data with DID-based access control.",
|
||||
"icon": "/assets/img/app-icons/dwn.svg",
|
||||
"author": "TBD",
|
||||
"dockerImage": "dwn-server:main",
|
||||
"repoUrl": "https://github.com/TBD54566975/dwn-server",
|
||||
"category": "data"
|
||||
"author": "TBD", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
|
||||
"repoUrl": "https://github.com/TBD54566975/dwn-server"
|
||||
},
|
||||
{
|
||||
"id": "cryptpad",
|
||||
"title": "CryptPad",
|
||||
"version": "2024.12.0",
|
||||
"description": "End-to-end encrypted documents and collaboration. Zero-knowledge.",
|
||||
"icon": "/assets/img/app-icons/cryptpad.webp",
|
||||
"author": "XWiki SAS",
|
||||
"dockerImage": "cryptpad:2024.12.0",
|
||||
"repoUrl": "https://github.com/cryptpad/cryptpad",
|
||||
"category": "data"
|
||||
"id": "endurain", "title": "Endurain", "version": "0.8.0",
|
||||
"description": "Self-hosted fitness tracking. Strava alternative.",
|
||||
"icon": "/assets/img/app-icons/endurain.png",
|
||||
"author": "Endurain", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
|
||||
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
|
||||
},
|
||||
{
|
||||
"id": "nostrudel",
|
||||
"title": "noStrudel",
|
||||
"version": "0.40.0",
|
||||
"description": "Feature-rich Nostr web client.",
|
||||
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||
"author": "hzrd149",
|
||||
"dockerImage": "",
|
||||
"repoUrl": "https://github.com/hzrd149/nostrudel",
|
||||
"webUrl": "https://nostrudel.ninja",
|
||||
"category": "nostr"
|
||||
},
|
||||
{
|
||||
"id": "nwnn",
|
||||
"title": "Next Web News Network",
|
||||
"version": "1.0.0",
|
||||
"description": "Decentralized news aggregator.",
|
||||
"icon": "/assets/img/app-icons/nwnn.png",
|
||||
"author": "L484",
|
||||
"dockerImage": "",
|
||||
"webUrl": "https://nwnn.l484.com",
|
||||
"category": "l484"
|
||||
},
|
||||
{
|
||||
"id": "484-kitchen",
|
||||
"title": "484 Kitchen",
|
||||
"version": "1.0.0",
|
||||
"description": "K484 application platform.",
|
||||
"icon": "/assets/img/app-icons/484-kitchen.png",
|
||||
"author": "L484",
|
||||
"dockerImage": "",
|
||||
"webUrl": "https://484.kitchen",
|
||||
"category": "l484"
|
||||
},
|
||||
{
|
||||
"id": "call-the-operator",
|
||||
"title": "Call the Operator",
|
||||
"version": "1.0.0",
|
||||
"description": "Escape the Matrix.",
|
||||
"icon": "/assets/img/app-icons/call-the-operator.png",
|
||||
"author": "TX1138",
|
||||
"dockerImage": "",
|
||||
"webUrl": "https://cta.tx1138.com",
|
||||
"category": "l484"
|
||||
},
|
||||
{
|
||||
"id": "arch-presentation",
|
||||
"title": "Arch Presentation",
|
||||
"version": "1.0.0",
|
||||
"description": "The Future of Decentralized Infrastructure.",
|
||||
"icon": "/assets/img/app-icons/arch-presentation.png",
|
||||
"author": "L484",
|
||||
"dockerImage": "",
|
||||
"webUrl": "https://present.l484.com",
|
||||
"category": "l484"
|
||||
},
|
||||
{
|
||||
"id": "syntropy-institute",
|
||||
"title": "Syntropy Institute",
|
||||
"version": "1.0.0",
|
||||
"description": "Medicine Reimagined.",
|
||||
"icon": "/assets/img/app-icons/syntropy-institute.png",
|
||||
"author": "Syntropy Institute",
|
||||
"dockerImage": "",
|
||||
"webUrl": "https://syntropy.institute",
|
||||
"category": "l484"
|
||||
},
|
||||
{
|
||||
"id": "t-zero",
|
||||
"title": "T-0",
|
||||
"version": "1.0.0",
|
||||
"description": "Documentary series exploring decentralization.",
|
||||
"icon": "/assets/img/app-icons/t-zero.png",
|
||||
"author": "T-0",
|
||||
"dockerImage": "",
|
||||
"webUrl": "https://teeminuszero.net",
|
||||
"category": "l484"
|
||||
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism"
|
||||
}
|
||||
],
|
||||
"registries": [
|
||||
"23.182.128.160:3000/lfg2025",
|
||||
"git.tx1138.com/lfg2025"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +284,29 @@ async function handleSplashComplete() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { isOnboardingComplete } = await import('@/composables/useOnboarding')
|
||||
const seenOnboarding = await isOnboardingComplete()
|
||||
const destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
router.push(destination).catch(() => {})
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
const seenOnboarding = await checkOnboardingStatus()
|
||||
if (seenOnboarding === true) {
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (seenOnboarding === false) {
|
||||
router.push('/onboarding/intro').catch(() => {})
|
||||
return
|
||||
}
|
||||
// Backend unreachable after retries. Prefer the localStorage
|
||||
// cache on THIS browser (if a prior successful check set it) —
|
||||
// otherwise defer to RootRedirect which polls + retries rather
|
||||
// than forcing an already-onboarded user through the wizard.
|
||||
if (localStorage.getItem('neode_onboarding_complete') === '1') {
|
||||
router.push('/login').catch(() => {})
|
||||
} else {
|
||||
router.push('/').catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
router.push('/onboarding/intro').catch(() => {})
|
||||
// Do NOT default to /onboarding/intro here. RootRedirect has retry
|
||||
// + polling + boot-screen handling; let it decide.
|
||||
router.push('/').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -73,7 +73,6 @@ const APP_ICON_MAP: Record<string, string> = {
|
||||
fedimint: '/assets/img/app-icons/fedimint.png',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
electrs: '/assets/img/app-icons/electrs.svg',
|
||||
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
|
||||
}
|
||||
|
||||
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
/**
|
||||
* Login screen audio: intro loop (MP3) + transition sounds.
|
||||
*
|
||||
* First-install vs returning-user gate: the synthwave loop, welcome
|
||||
* voice, pop/whoosh/oomph transitions exist for the first-boot cinematic
|
||||
* moment. After the user has completed onboarding we silence all of
|
||||
* them — every subsequent login should be quiet. Typing sounds are
|
||||
* exempt and continue to play regardless.
|
||||
*/
|
||||
|
||||
/** True when the node has not yet completed onboarding — i.e. we're
|
||||
* still in the first-install cinematic. Reads the localStorage cache
|
||||
* set by useOnboarding (which is re-seeded from the backend on each
|
||||
* successful check), so this stays correct after a browser clear
|
||||
* once the onboarding-complete probe runs. Sound calls that fire
|
||||
* before that probe completes will fall through silent on an already-
|
||||
* onboarded node — which is exactly what we want. */
|
||||
function isFirstInstallPhase(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('neode_onboarding_complete') !== '1'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let introAudio: HTMLAudioElement | null = null
|
||||
let introGain: GainNode | null = null
|
||||
@@ -30,6 +51,7 @@ const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||||
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in.
|
||||
* Uses Web Audio API so it plays after context is resumed (user gesture). */
|
||||
export function playLoopStart() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
try {
|
||||
@@ -62,6 +84,7 @@ export function resumeAudioContext() {
|
||||
|
||||
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
|
||||
export function startSynthwave() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const ctxOrNull = getContext()
|
||||
if (!ctxOrNull) return
|
||||
|
||||
@@ -123,6 +146,7 @@ export function stopAllAudio() {
|
||||
|
||||
/** Pop sound - plays when intro initiator (tap to start) is pressed */
|
||||
export function playPop() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const audio = new Audio('/assets/audio/pop.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
@@ -130,6 +154,7 @@ export function playPop() {
|
||||
|
||||
/** Whoosh transition on successful login */
|
||||
export function playLoginSuccessWhoosh() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const woosh = new Audio('/assets/audio/woosh.mp3')
|
||||
woosh.volume = 0.5
|
||||
woosh.play().catch(() => {})
|
||||
@@ -169,6 +194,7 @@ const WELCOME_SPEECH_URL = '/assets/audio/welcome-noderunner.mp3'
|
||||
* ELEVENLABS_API_KEY=your_key node neode-ui/scripts/generate-welcome-speech.js
|
||||
* Browse sci-fi voices at elevenlabs.io/voice-library and set ELEVENLABS_VOICE_ID for custom voice. */
|
||||
export function playWelcomeNoderunnerSpeech() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const audio = new Audio(WELCOME_SPEECH_URL)
|
||||
audio.volume = 0.9
|
||||
audio.play().catch(() => {})
|
||||
@@ -226,6 +252,7 @@ export function playKeyboardTypingSound() {
|
||||
|
||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||
export function playDashboardLoadOomph() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
|
||||
@@ -1,29 +1,64 @@
|
||||
/**
|
||||
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
|
||||
* Hardened: retries on 502/503, never blocks completion.
|
||||
* Onboarding state - backend is authoritative.
|
||||
* "Unknown" (backend unreachable) must NEVER default to false —
|
||||
* that would falsely send an already-onboarded user back through
|
||||
* the intro after a browser clear / update / reboot.
|
||||
*/
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T | null> {
|
||||
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T | null> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
const isRetryable = /502|503|timeout|fetch|network/i.test(msg)
|
||||
const isRetryable = /502|503|504|timeout|fetch|network|abort/i.test(msg)
|
||||
if (!isRetryable || i === maxRetries - 1) return null
|
||||
await new Promise((r) => setTimeout(r, 800 * (i + 1)))
|
||||
// Exponential-ish backoff: 500, 1000, 2000, 4000, 8000 (capped)
|
||||
const delay = Math.min(500 * Math.pow(2, i), 8000)
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true/false if the backend gave a definitive answer, null if
|
||||
* the backend is unreachable. Callers MUST handle null explicitly —
|
||||
* do not coerce to boolean without thinking about the consequences.
|
||||
*/
|
||||
export async function checkOnboardingStatus(): Promise<boolean | null> {
|
||||
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 5)
|
||||
if (result !== null) {
|
||||
if (result) {
|
||||
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
|
||||
} else {
|
||||
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean-only variant for places that genuinely cannot wait.
|
||||
* Backend answer wins; on backend-unreachable, trusts a prior
|
||||
* localStorage cache (set by a past successful check on THIS node).
|
||||
* Returns false only when both the backend and the cache agree —
|
||||
* or when the cache is empty on a genuinely fresh install.
|
||||
*
|
||||
* Prefer checkOnboardingStatus() where possible so the caller can
|
||||
* distinguish "confirmed fresh install" from "can't reach backend".
|
||||
*/
|
||||
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
|
||||
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
|
||||
const result = await checkOnboardingStatus()
|
||||
if (result !== null) return result
|
||||
return false
|
||||
// Backend unreachable — trust the local cache. If the cache says
|
||||
// we're onboarded, we almost certainly are (this browser saw a
|
||||
// prior backend 'true' and re-seeded the flag). If the cache is
|
||||
// empty, we genuinely don't know; returning false here is the
|
||||
// last-resort fallback, and the calling views should additionally
|
||||
// keep polling the backend instead of treating this as gospel.
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"launch": "Launch",
|
||||
"starting": "Starting...",
|
||||
"stopping": "Stopping...",
|
||||
"update": "Update",
|
||||
"updating": "Updating...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"back": "Back",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"launch": "Abrir",
|
||||
"starting": "Iniciando...",
|
||||
"stopping": "Deteniendo...",
|
||||
"update": "Actualizar",
|
||||
"updating": "Actualizando...",
|
||||
"send": "Enviar",
|
||||
"sending": "Enviando...",
|
||||
"back": "Volver",
|
||||
|
||||
@@ -311,18 +311,35 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// Check if this is a fresh install that needs onboarding
|
||||
// Check if this is a fresh install that needs onboarding.
|
||||
// Prefer checkOnboardingStatus() (tri-state) so we can distinguish
|
||||
// "confirmed fresh install" from "backend unreachable". On the
|
||||
// latter, send the user to RootRedirect (/) rather than the intro
|
||||
// wizard — RootRedirect polls the backend and will route to
|
||||
// /login once it answers, instead of forcing a re-onboarding.
|
||||
try {
|
||||
const { isOnboardingComplete, getSavedOnboardingStep } = await import('@/composables/useOnboarding')
|
||||
const setupDone = await isOnboardingComplete()
|
||||
if (!setupDone) {
|
||||
const { checkOnboardingStatus, getSavedOnboardingStep } = await import('@/composables/useOnboarding')
|
||||
const setupDone = await checkOnboardingStatus()
|
||||
if (setupDone === false) {
|
||||
const step = getSavedOnboardingStep()
|
||||
next(`/onboarding/${step}`)
|
||||
return
|
||||
}
|
||||
if (setupDone === null) {
|
||||
// Backend unreachable after retries — bounce through RootRedirect
|
||||
// so it can keep polling and land the user on /login once the
|
||||
// backend answers, instead of flashing the onboarding wizard.
|
||||
const cached = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
if (!cached) {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
// Cached as onboarded — continue to the /login path below.
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, assume fresh install and show onboarding
|
||||
next('/onboarding/intro')
|
||||
// Unexpected error — do NOT default to onboarding. Hand off to
|
||||
// RootRedirect which has retry + polling + boot-screen handling.
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -41,7 +41,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'9001': 'penpot',
|
||||
'9980': 'onlyoffice',
|
||||
'11434': 'ollama',
|
||||
'2283': 'immich',
|
||||
@@ -51,7 +50,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'8175': 'fedimint',
|
||||
'8176': 'fedimint-gateway',
|
||||
'3100': 'dwn',
|
||||
'18081': 'nostr-rs-relay',
|
||||
'7777': 'indeedhub',
|
||||
'50002': 'electrumx',
|
||||
'3010': 'thunderhub',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface PackageDataEntry {
|
||||
manifest: Manifest
|
||||
installed?: InstalledPackageDataEntry
|
||||
'install-progress'?: InstallProgress
|
||||
/** Live label for the current uninstall step ("Stopping containers (2/5)", …). */
|
||||
'uninstall-stage'?: string | null
|
||||
'available-update'?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -474,42 +474,6 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'penpot': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MPL-2.0',
|
||||
instructions: 'Design and prototyping platform',
|
||||
icon: '/assets/img/penpot.webp'
|
||||
},
|
||||
manifest: {
|
||||
id: 'penpot',
|
||||
title: 'Penpot',
|
||||
version: '2.0.0',
|
||||
description: {
|
||||
short: 'Open-source design and prototyping platform',
|
||||
long: 'Penpot is an open-source design and prototyping platform for teams. Create designs, prototypes, and collaborate in real-time. Self-hosted alternative to Figma.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MPL-2.0',
|
||||
'wrapper-repo': 'https://github.com/penpot/penpot',
|
||||
'upstream-repo': 'https://github.com/penpot/penpot',
|
||||
'support-site': 'https://github.com/penpot/penpot/issues',
|
||||
'marketing-site': 'https://penpot.app',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'penpot.onion',
|
||||
'lan-address': 'http://localhost:9001'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'indeedhub': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
@start="actions.startApp"
|
||||
@stop="actions.stopApp"
|
||||
@restart="actions.restartApp"
|
||||
@update="updateApp"
|
||||
@show-uninstall="showUninstallModal"
|
||||
/>
|
||||
</div>
|
||||
@@ -282,6 +283,9 @@ function closeUninstallModal() {
|
||||
|
||||
async function onConfirmUninstall() {
|
||||
const { appId } = uninstallModal.value
|
||||
// Close the modal immediately so the user can fire off concurrent
|
||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||
// its uninstall is in flight.
|
||||
uninstallModal.value.show = false
|
||||
await actions.confirmUninstall(appId)
|
||||
}
|
||||
@@ -293,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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -178,7 +178,6 @@ const APP_ICON_MAP: Record<string, string> = {
|
||||
fedimint: '/assets/img/app-icons/fedimint.png',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
electrs: '/assets/img/app-icons/electrs.svg',
|
||||
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
|
||||
}
|
||||
|
||||
function stepIconUrl(step: GoalStep): string | undefined {
|
||||
|
||||
@@ -99,7 +99,6 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'searxng': '/app/searxng/',
|
||||
'ollama': '/app/ollama/',
|
||||
'penpot': '/app/penpot/',
|
||||
'onlyoffice': '/app/onlyoffice/',
|
||||
'portainer': '/app/portainer/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
@@ -108,7 +107,6 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'dwn': '/app/dwn/',
|
||||
'nostr-rs-relay': '/app/nostr-rs-relay/',
|
||||
'indeedhub': 'http://localhost:8190',
|
||||
'botfights': 'http://localhost:9100',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -50,11 +50,14 @@ async function quickHealthCheck(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function checkOnboarded(): Promise<boolean> {
|
||||
// No hard timeout here. isOnboardingComplete() already retries with
|
||||
// backoff (see useOnboarding.ts). A 3s Promise.race that resolves to
|
||||
// `false` on timeout was previously the main cause of the intro
|
||||
// flashing on already-onboarded nodes after browser-clear / reboot /
|
||||
// update: if the backend was slow to warm up, we'd force a 'false'
|
||||
// and route the user back through the setup wizard.
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
isOnboardingComplete(),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
|
||||
])
|
||||
const result = await isOnboardingComplete()
|
||||
log('checkOnboarded', { result })
|
||||
return result
|
||||
} catch (e) {
|
||||
@@ -129,7 +132,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>
|
||||
|
||||
@@ -34,7 +34,6 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
searxng: 'searxng',
|
||||
ollama: 'ollama',
|
||||
onlyoffice: 'onlyoffice',
|
||||
penpot: 'penpot',
|
||||
nextcloud: 'nextcloud',
|
||||
vaultwarden: 'vaultwarden',
|
||||
jellyfin: 'jellyfin',
|
||||
@@ -79,7 +78,6 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
|
||||
'ollama': { dev: 'http://localhost:11434', prod: 'http://localhost:11434' },
|
||||
'searxng': { dev: 'http://localhost:8888', prod: 'http://localhost:8888' },
|
||||
'onlyoffice': { dev: 'http://localhost:9980', prod: 'http://localhost:9980' },
|
||||
'penpot': { dev: 'http://localhost:9001', prod: 'http://localhost:9001' },
|
||||
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
|
||||
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
|
||||
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
||||
export const APP_PORTS: Record<string, number> = {
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
@@ -23,7 +24,6 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'searxng': 8888,
|
||||
'ollama': 11434,
|
||||
'onlyoffice': 8044,
|
||||
'penpot': 9001,
|
||||
'nextcloud': 8085,
|
||||
'vaultwarden': 8082,
|
||||
'jellyfin': 8096,
|
||||
@@ -37,10 +37,6 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'fedimint': 8175,
|
||||
'fedimintd': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'nostr-rs-relay': 18081,
|
||||
'nostr-vpn': 8201,
|
||||
'fips': 8202,
|
||||
'routstr': 8200,
|
||||
'indeedhub': 7778,
|
||||
'botfights': 9100,
|
||||
'gitea': 3000,
|
||||
@@ -57,6 +53,7 @@ export const PROXY_APPS: Record<string, string> = {}
|
||||
* On HTTP, direct port access is used instead (faster, no proxy). */
|
||||
export const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'bitcoin-core': '/app/bitcoin-ui/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
'lnd': '/app/lnd/',
|
||||
'electrumx': '/app/electrs/',
|
||||
@@ -85,14 +82,10 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
'dwn': '/app/dwn/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'penpot': '/app/penpot/',
|
||||
'grafana': '/app/grafana/',
|
||||
'indeedhub': '/app/indeedhub/',
|
||||
'botfights': '/app/botfights/',
|
||||
'gitea': '/app/gitea/',
|
||||
'routstr': '/app/routstr/',
|
||||
'nostr-vpn': '/app/nostr-vpn/',
|
||||
'fips': '/app/fips/',
|
||||
}
|
||||
|
||||
/** External HTTPS apps -- always loaded directly */
|
||||
@@ -107,11 +100,11 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
}
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
'bitcoin-knots': 'Bitcoin', 'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
||||
'nostr-vpn': 'Nostr VPN', 'fips': 'FIPS', 'routstr': 'Routstr',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager', 'nostr-rs-relay': 'Nostr Relay',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||
'call-the-operator': 'Call The Operator', 'syntropy-institute': 'Syntropy Institute',
|
||||
't-zero': 'T-Zero', 'nostrudel': 'noStrudel',
|
||||
}
|
||||
@@ -125,12 +118,10 @@ export const NEW_TAB_APPS = new Set([
|
||||
'vaultwarden',
|
||||
'nextcloud',
|
||||
'uptime-kuma',
|
||||
'penpot',
|
||||
'portainer',
|
||||
'onlyoffice',
|
||||
'nginx-proxy-manager',
|
||||
'tailscale',
|
||||
'routstr',
|
||||
])
|
||||
|
||||
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SelectedIdentity {
|
||||
}
|
||||
|
||||
function isIdentityAwareApp(id: string): boolean {
|
||||
return id === 'indeedhub' || id === 'nostrudel' || id === 'routstr'
|
||||
return id === 'indeedhub' || id === 'nostrudel'
|
||||
}
|
||||
|
||||
export function useAppIdentity(
|
||||
|
||||
@@ -99,14 +99,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstalling progress — replaces action buttons -->
|
||||
<!-- Uninstalling progress — live stage label from backend -->
|
||||
<div v-else-if="isUninstalling" class="mt-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3 text-red-400" 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>
|
||||
<span class="text-xs text-red-300">{{ t('common.uninstalling') }}...</span>
|
||||
<span class="text-xs text-red-300 truncate">{{ uninstallStageLabel }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
|
||||
@@ -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]
|
||||
}>()
|
||||
|
||||
@@ -251,6 +275,13 @@ const tier = computed(() => {
|
||||
return 'optional'
|
||||
})
|
||||
|
||||
// Live uninstall stage from backend, with a sensible fallback so the
|
||||
// label is never blank between WS pushes.
|
||||
const uninstallStageLabel = computed(() => {
|
||||
const raw = props.pkg['uninstall-stage']
|
||||
return raw ? raw : `${t('common.uninstalling')}…`
|
||||
})
|
||||
|
||||
const isTransitioning = computed(() => {
|
||||
const s = props.pkg.state
|
||||
const h = props.pkg.health
|
||||
|
||||
@@ -8,11 +8,9 @@ import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
'immich_postgres', 'immich_redis',
|
||||
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'archy-nostr-vpn-ui', 'archy-fips-ui',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
@@ -39,8 +37,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
|
||||
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
|
||||
'nostr-vpn': 'networking', 'fips': 'networking', 'routstr': 'community',
|
||||
'nostrudel': 'nostr',
|
||||
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
'uptime-kuma': 'networking', 'dwn': 'data',
|
||||
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
|
||||
@@ -99,7 +96,7 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
export const TAB_LAUNCH_APPS = new Set([
|
||||
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer',
|
||||
'cryptpad', 'nginx-proxy-manager', 'tailscale', 'routstr',
|
||||
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
||||
])
|
||||
|
||||
export function opensInTab(id: string): boolean {
|
||||
|
||||
@@ -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: 'docker.io/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' },
|
||||
@@ -85,7 +86,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||
{ id: 'penpot', title: 'Penpot', version: '2.4', description: 'Open-source design platform. Self-hosted alternative to Figma for design and prototyping.', icon: '/assets/img/app-icons/penpot.webp', author: 'Penpot', dockerImage: `${R}/penpot-frontend:2.4`, repoUrl: 'https://github.com/penpot/penpot' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '28', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:28`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||
@@ -98,12 +98,8 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ id: 'nostr-rs-relay', title: 'Nostr Relay', version: '0.9.0', category: 'nostr', description: 'Your own Nostr relay. Store events locally, relay for friends, publish over Tor.', icon: '/assets/img/app-icons/nostr-rs-relay.svg', author: 'scsiblade', dockerImage: `${R}/nostr-rs-relay:0.9.0`, repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
|
||||
{ id: 'nostr-vpn', title: 'Nostr VPN', version: '0.3.7', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', dockerImage: `${R}/nostr-vpn:v0.3.7`, repoUrl: 'https://github.com/mmalmi/nostr-vpn' },
|
||||
{ id: 'fips', title: 'FIPS', version: '0.1.0', category: 'networking', description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.', icon: '/assets/img/app-icons/fips.svg', author: 'Jim Corgan', dockerImage: `${R}/fips:v0.1.0`, repoUrl: 'https://github.com/jmcorgan/fips' },
|
||||
{ id: 'routstr', title: 'Routstr', version: '0.4.3', category: 'community', description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.', icon: '/assets/img/app-icons/routstr.svg', author: 'Routstr', dockerImage: `${R}/routstr:v0.4.3`, repoUrl: 'https://github.com/routstr/routstr-core' },
|
||||
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
||||
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
|
||||
@@ -138,9 +134,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
tailscale: ['tailscale'],
|
||||
ollama: ['ollama'],
|
||||
indeedhub: ['indeedhub'],
|
||||
'nostr-vpn': ['nostr-vpn'],
|
||||
fips: ['fips'],
|
||||
routstr: ['routstr'],
|
||||
botfights: ['botfights'],
|
||||
}
|
||||
|
||||
@@ -164,6 +157,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.',
|
||||
|
||||
@@ -96,20 +96,32 @@
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Installing — simple button-style indicator -->
|
||||
<button
|
||||
<!-- Installing — live progress with bar + message matching the
|
||||
update download bar's accuracy. Falls back to a simple
|
||||
spinner if no install_progress data is available yet. -->
|
||||
<div
|
||||
v-else-if="!installed && installing"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-80 cursor-wait"
|
||||
class="flex-1 flex flex-col gap-1.5"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<div
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-90 cursor-wait flex items-center justify-center gap-2 text-center"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5 shrink-0" 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>
|
||||
Installing
|
||||
</span>
|
||||
</button>
|
||||
<span class="truncate">{{ installProgressMessage }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="(installProgress?.progress ?? 0) > 0"
|
||||
class="w-full h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-orange-400 transition-all duration-300"
|
||||
:style="{ width: (installProgress?.progress ?? 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
|
||||
data-controller-install-btn
|
||||
@@ -130,6 +142,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
||||
|
||||
@@ -154,6 +167,14 @@ defineEmits<{
|
||||
launch: [app: MarketplaceApp]
|
||||
}>()
|
||||
|
||||
const installProgressMessage = computed(() => {
|
||||
const p = props.installProgress
|
||||
if (!p) return 'Installing'
|
||||
// The store already formats messages like "Downloading: 50.5 / 200.0 MB (25%)"
|
||||
// so we just surface them directly.
|
||||
return p.message || 'Installing'
|
||||
})
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
|
||||
@@ -54,9 +54,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
ollama: ['ollama'],
|
||||
'nostr-vpn': ['nostr-vpn'],
|
||||
fips: ['fips'],
|
||||
routstr: ['routstr'],
|
||||
}
|
||||
|
||||
/** Get app tier classification (matches backend get_app_tier) */
|
||||
@@ -240,17 +237,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/cryptpad/cryptpad'
|
||||
},
|
||||
{
|
||||
id: 'penpot',
|
||||
title: 'Penpot',
|
||||
version: '2.4',
|
||||
description: 'Open-source design and prototyping platform. Self-hosted alternative to Figma.',
|
||||
icon: '/assets/img/app-icons/penpot.webp',
|
||||
author: 'Penpot',
|
||||
dockerImage: 'docker.io/penpotapp/frontend:2.4',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/penpot/penpot'
|
||||
},
|
||||
{
|
||||
id: 'nextcloud',
|
||||
title: 'Nextcloud',
|
||||
@@ -405,42 +391,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/TBD54566975/dwn-server'
|
||||
},
|
||||
{
|
||||
id: 'nostr-vpn',
|
||||
title: 'Nostr VPN',
|
||||
version: '0.3.7',
|
||||
category: 'networking',
|
||||
description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.',
|
||||
icon: '/assets/img/app-icons/nostr-vpn.svg',
|
||||
author: 'Martti Malmi',
|
||||
dockerImage: `${REGISTRY}/nostr-vpn:v0.3.7`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/mmalmi/nostr-vpn'
|
||||
},
|
||||
{
|
||||
id: 'fips',
|
||||
title: 'FIPS',
|
||||
version: '0.1.0',
|
||||
category: 'networking',
|
||||
description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.',
|
||||
icon: '/assets/img/app-icons/fips.svg',
|
||||
author: 'Jim Corgan',
|
||||
dockerImage: `${REGISTRY}/fips:v0.1.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/jmcorgan/fips'
|
||||
},
|
||||
{
|
||||
id: 'routstr',
|
||||
title: 'Routstr',
|
||||
version: '0.4.3',
|
||||
category: 'community',
|
||||
description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.',
|
||||
icon: '/assets/img/app-icons/routstr.svg',
|
||||
author: 'Routstr',
|
||||
dockerImage: `${REGISTRY}/routstr:v0.4.3`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/routstr/routstr-core'
|
||||
},
|
||||
{
|
||||
id: 'nostrudel',
|
||||
title: 'noStrudel',
|
||||
@@ -454,18 +404,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
repoUrl: 'https://github.com/hzrd149/nostrudel',
|
||||
webUrl: 'https://nostrudel.ninja'
|
||||
},
|
||||
{
|
||||
id: 'nostr-rs-relay',
|
||||
title: 'Nostr Relay',
|
||||
version: '0.9.0',
|
||||
category: 'nostr',
|
||||
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
|
||||
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
|
||||
author: 'scsiblade',
|
||||
dockerImage: 'docker.io/scsibug/nostr-rs-relay:0.9.0',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
|
||||
},
|
||||
{
|
||||
id: 'botfights',
|
||||
title: 'BotFights',
|
||||
|
||||
@@ -180,6 +180,144 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.41-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.41-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Updates now self-check. After an update lands, the node probes its own web UI through nginx — if the frontend isn't answering cleanly within 90 seconds, the node automatically rolls back to the previous version and restarts. A bad release can no longer leave the fleet stranded on an unreachable node.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.40-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.40-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Proper fix for the 500 / Internal Server Error after update. The v1.7.38 and v1.7.39 frontend archives had the wrong permissions baked into the archive itself — the tarball's root directory entry was private, so every node that extracted it ended up with a web UI directory nginx couldn't read. v1.7.40 packages the archive with correct world-readable permissions from the start, so no node ever sees the 500 again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.39-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.39-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Hotfix for v1.7.38 — on some nodes the update landed with the web UI directory set to private file permissions, so nginx returned a 500 / "Internal Server Error" on every page. This release fixes the updater to set world-readable permissions on the new frontend, and the node also now self-heals on boot if it ever finds the UI directory in that state again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.38-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.38-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Signing in is quiet now. The intro music, welcome voice, and transition sounds belong to the first-boot cinematic and only play before you've finished onboarding — every login after that is silent. Typing sounds in the search bar and on the dashboard are unaffected.</p>
|
||||
<p>Fixed a bug where clearing your browser cache, updating the node, or rebooting could bounce you back through the onboarding wizard even though your node was already fully set up. The node now self-heals: if your password is set, it knows you've been through onboarding and takes you straight to the login screen. No more starting over.</p>
|
||||
<p>Trimmed the App Store. FIPS, Nostr Relay, Nostr VPN, Routstr, and Penpot have been removed from the catalog and their container images deleted from our registries. Your node's native FIPS transport is untouched — this is just the app-store entries going away.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.37-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.37-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Core (the reference implementation) now installs from the App Store and runs cleanly alongside Bitcoin Knots as a first-class option. The install flow pulls the official docker.io/bitcoin image directly if your internal mirrors don't carry it, and the node UI auto-detects which implementation is running so the logo, title, and version line all reflect Core vs. Knots without any manual config.</p>
|
||||
<p>The node dashboard now shows a Storage indicator (Full Archive · X GB or Pruned · X GB) right next to Network, so you can tell at a glance whether your node is carrying the full chain history or the last ~550 MB. The Node Settings modal was stripped of its hardcoded Regtest/port-18443 placeholders and now shows real values — network mode, storage mode, transaction index, ZMQ publishing, and RPC port — all read from the running node.</p>
|
||||
<p>Fresh installs no longer default to pruned mode. Previously, a new install would write <code>prune=550</code> into bitcoin.conf even on boxes with 2 TB of free space; now the default is full archive and you can opt into pruning by editing the conf yourself.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.36-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.36-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Core joins the App Store as its own entry, with the Umbrel community icon and a description that frames it as a reference alternative to Bitcoin Knots rather than a replacement. A Sovereignty Stack tile on the Discover page now groups your node options together so the choice is obvious.</p>
|
||||
<p>The App Store catalog fetch now follows whichever container registries you've set as primary in Settings. Previously the catalog URL was hardcoded to two servers; now the operator's own mirror priority drives where the App Store pulls its listings from, so switching primary actually moves the catalog too.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.35-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.35-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Rootless-netns self-heal: if the container network loses its outbound tap (symptom: Bitcoin Knots and other outbound containers can't reach the internet even though container-to-container still works), the node now detects it and restarts the network from scratch on its own. No more having to SSH in and bounce podman.</p>
|
||||
<p>Every app card on the Apps page now has an Update button whenever a newer version of the app is available — same flow as the detail view, one click away. Updating apps used to require drilling into each card individually.</p>
|
||||
<p>Your node's Web5 DID, Identities list, and peer-to-peer pubkey now all resolve to the same seed-derived identity instead of drifting apart after onboarding. The Node identity on fresh installs is mirrored from the onboarding seed rather than generated as a separate random keypair.</p>
|
||||
<p>Bitcoin Knots (and the new Bitcoin Core slot) now run on bitcoin/bitcoin 28.4 with a realistic 4 GiB memory cap and uncapped CPUs so bitcoind can run -par=auto across every core on your box.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.34-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.34-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The login background now rotates through six atmospheric images, advancing one each time you land on the login screen, so returning to your node doesn't keep showing the same wallpaper. The chosen index is remembered across logouts.</p>
|
||||
<p>Re-logging in is noticeably snappier. The dashboard entry animation used to replay the full 1.2-second zoom reveal on every login; that's now reserved for the first entry after onboarding. Subsequent logins fade in with just the welcome typing in about 300 ms.</p>
|
||||
<p>If you clear site data on a node you've already onboarded, the intro video no longer fires again on the login screen. The onboarding cache is re-seeded from the backend automatically, so /login stays quiet instead of replaying the whole intro sequence.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.33-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.33-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The onboarding wizard no longer gets skipped on genuinely-fresh nodes when you connect from a browser that onboarded a different node earlier. The backend is now the source of truth for "has this node been onboarded yet?" — the browser's local flag is the offline fallback, not the primary answer.</p>
|
||||
<p>Already-onboarded nodes no longer show the "boot loader" or "server starting up" screens during an OTA update blip. The health check polls quietly for up to a minute before showing the boot screen, so a 10-second restart no longer looks like a catastrophic failure.</p>
|
||||
<p>Logging out and returning to <code>/login</code> no longer replays the full intro video — you get a quiet lock-screen background instead. The full welcome sequence is reserved for genuine first-time entries.</p>
|
||||
<p>Upgrading nodes now pick up this release's UI cleanly without a stale cache hanging on. A cache-version bump tells your browser's service worker to ditch the old bundle on first load.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.32-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.32-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Hotfix: v1.7.31's frontend tarball was packaged with an extra wrapper directory, which left some nodes serving 403/500 after applying the update instead of the new UI. This release ships the tarball with the correct flat layout, and broken nodes heal automatically when this update applies.</p>
|
||||
<p>Updates now finalize cleanly instead of being force-killed by systemd. Previously the node logged "shut down cleanly" during an update, then systemd waited 15 seconds and SIGKILL'd the service because one of the internal threads wasn't releasing. That's been tracked down and fixed, so the service exits promptly and the restart path is snappier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.31-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.31-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 22, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>IndeedHub install is now idempotent — re-running it after a failed first attempt no longer leaves orphaned containers blocking the retry with a "name already in use" error. The installer force-cleans leftover containers and the dedicated network before starting a fresh stack.</p>
|
||||
<p>Server 3 (OVH) is now an automatic tertiary mirror for both system updates and app registries. Existing nodes pick it up on next restart without any manual config — another independent network path, so a single-provider outage can't stall downloads.</p>
|
||||
<p>The reachability test on the Registries page no longer reports false "unreachable" for Gitea-backed registries. The probe now hits the Docker V2 API at the correct host-root path and accepts HTTP 405 in addition to 200/401 as "registry alive".</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.30-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.30-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App installs now show a real download progress bar — same accuracy as the system update bar. You'll see "Downloading: 50.5 / 200.0 MB (25%)" with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a "stuck at 0%" again.</p>
|
||||
<p>Uninstalls now show what's actually happening: "Stopping containers (2/5)", "Cleaning up volumes", "Removing app data" — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.</p>
|
||||
<p>OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry — extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.29-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -65,13 +65,65 @@ if [ -z "$BACKEND_BINARY" ]; then
|
||||
BACKEND_BINARY="$PROJECT_ROOT/core/target/release/archipelago"
|
||||
fi
|
||||
|
||||
# Auto-detect frontend archive
|
||||
# Auto-detect frontend archive.
|
||||
# Layout: flat tarball (`./index.html`, `./assets/…`, `./aiui/…`) so the
|
||||
# Rust updater can unpack it directly into /opt/archipelago/web-ui/.
|
||||
# Using `-C web/dist neode-ui` would produce a `neode-ui/` prefix which
|
||||
# breaks the installer and returns 403 on every fleet UI — see
|
||||
# feedback_release_tarball_layout.md.
|
||||
if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
FRONTEND_DIST="$PROJECT_ROOT/web/dist/neode-ui"
|
||||
if [ -d "$FRONTEND_DIST" ]; then
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
echo "Creating frontend archive from $FRONTEND_DIST..."
|
||||
tar -czf "$FRONTEND_ARCHIVE" -C "$PROJECT_ROOT/web/dist" neode-ui
|
||||
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
||||
echo "Staging frontend archive in $STAGING_DIR..."
|
||||
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
||||
# Bake AIUI in so fresh installs pick it up. OTA already
|
||||
# carries-forward the existing aiui/ if the tarball lacks one
|
||||
# (update.rs:922), but including it here makes the tarball
|
||||
# the single source of truth instead of relying on a side-
|
||||
# effect of the in-place swap.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
echo " Including AIUI from demo/aiui/"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
|
||||
fi
|
||||
# Force world-readable perms on every entry BEFORE tar, so the
|
||||
# archive's internal mode bits are 755/644 regardless of what
|
||||
# the staging dir's umask gave us. Without this, mktemp -d
|
||||
# creates the staging dir at 700, that 700 gets baked into the
|
||||
# tarball's root `./` entry, and every node that extracts the
|
||||
# archive ends up with /opt/archipelago/web-ui at 700 — which
|
||||
# causes nginx (www-data) to return 500 "permission denied" on
|
||||
# every page. Bit us on the v1.7.38 + v1.7.39 rollouts.
|
||||
chmod 755 "$STAGING_DIR"
|
||||
find "$STAGING_DIR" -type d -exec chmod 755 {} +
|
||||
find "$STAGING_DIR" -type f -exec chmod 644 {} +
|
||||
echo "Creating frontend archive $FRONTEND_ARCHIVE..."
|
||||
# --mode is a belt-and-braces in case a file's on-disk perms
|
||||
# drift again; forces 755 dir / 644 file in the archive too.
|
||||
tar --owner=0 --group=0 \
|
||||
--mode='u=rwX,go=rX' \
|
||||
-czf "$FRONTEND_ARCHIVE" \
|
||||
-C "$STAGING_DIR" .
|
||||
# Verify the archive root entry is world-readable before we
|
||||
# declare success — catches regressions in tar-flag handling
|
||||
# (BSD tar, busybox tar) that might silently drop --mode.
|
||||
# SIGPIPE-safe: use awk to read only the first line and exit,
|
||||
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
||||
# don't kill the whole `set -euo pipefail` script.
|
||||
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
|
||||
case "$root_mode" in
|
||||
drwxr-xr-x|drwxr-x*x*)
|
||||
echo " Tarball root perms OK: $root_mode"
|
||||
;;
|
||||
*)
|
||||
echo " ERROR: tarball root perms are $root_mode (want drwxr-xr-x) — aborting release"
|
||||
rm -f "$FRONTEND_ARCHIVE"
|
||||
rm -rf "$STAGING_DIR"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
rm -rf "$STAGING_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -600,6 +600,13 @@ if [ "$LIVE" = true ]; then
|
||||
echo "$(timestamp) Building AIUI (source newer than dist or dist missing)..."
|
||||
(cd "$AIUI_DIR" && VITE_BASE_PATH=/aiui/ pnpm build 2>&1 | tail -5) || echo "$(timestamp) ⚠️ AIUI build failed"
|
||||
fi
|
||||
# Fallback: if the AIUI sibling checkout is missing, use the pre-built
|
||||
# dist shipped in this repo at demo/aiui/. That path is what we ship in
|
||||
# the release tarball too, so local-and-fleet-update stay consistent.
|
||||
if [ ! -f "$AIUI_DIST/index.html" ] && [ -f "$PROJECT_DIR/demo/aiui/index.html" ]; then
|
||||
echo "$(timestamp) AIUI sibling dist missing — using demo/aiui/ from repo"
|
||||
AIUI_DIST="$PROJECT_DIR/demo/aiui"
|
||||
fi
|
||||
if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then
|
||||
echo "$(timestamp) Deploying AIUI..."
|
||||
if true; then
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||